feat: support adaptive traffic units and precise quotas

This commit is contained in:
sagitchu
2026-09-24 15:33:53 +08:00
parent 2e845030de
commit 5d60c4fbe1
21 changed files with 579 additions and 173 deletions
+3
View File
@@ -19,6 +19,7 @@ export interface UserApiItem {
name?: string;
status: number;
flow: number;
flowMiB?: number;
num: number;
expTime?: number;
flowResetTime?: number;
@@ -106,6 +107,7 @@ export interface UserTunnelPermissionApiItem {
tunnelName: string;
status: number;
flow: number;
flowMiB?: number;
num: number;
expTime: number;
flowResetTime: number;
@@ -387,6 +389,7 @@ export interface UserTunnelAssignPayload {
id?: number;
tunnelId?: number;
flow?: number;
flowMiB?: number;
num?: number;
expTime?: number;
flowResetTime?: number;
@@ -0,0 +1,62 @@
import { Input } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
parseTrafficInput,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
const UNITS: TrafficUnit[] = ["MB", "GB", "TB", "PB"];
interface TrafficLimitFieldProps {
label: string;
value: string;
unit: TrafficUnit;
onChange: (value: string, unit: TrafficUnit) => void;
description?: string;
isRequired?: boolean;
}
export function TrafficLimitField({
label,
value,
unit,
onChange,
description,
isRequired,
}: TrafficLimitFieldProps) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_7rem] gap-2">
<Input
description={description}
isRequired={isRequired}
label={label}
min="0"
step="any"
type="number"
value={value}
onChange={(event) => onChange(event.target.value, unit)}
/>
<Select
aria-label={`${label}单位`}
label="单位"
selectedKeys={[unit]}
onSelectionChange={(keys) => {
const nextUnit = Array.from(keys)[0] as TrafficUnit | undefined;
if (!nextUnit) return;
const mib = parseTrafficInput(value, unit);
onChange(
mib === null ? value : String(mib / TRAFFIC_UNIT_MIB[nextUnit]),
nextUnit,
);
}}
>
{UNITS.map((option) => (
<SelectItem key={option}>{option}</SelectItem>
))}
</Select>
</div>
);
}
+15 -27
View File
@@ -24,6 +24,11 @@ import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
import { getSessionName } from "@/utils/session";
import { safeLogout } from "@/utils/logout";
import {
formatTraffic,
formatFlowLimit,
flowLimitBytes,
} from "@/utils/traffic";
import {
formatNodeRenewalTime,
getNodeRenewalCycleLabel,
@@ -70,24 +75,7 @@ export default function DashboardPage() {
const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<AddressItem[]>([]);
const formatFlow = (value: number, unit: string = "bytes"): string => {
// 99999 表示无限制
if (value === 99999) {
return "无限制";
}
if (unit === "gb") {
return value + " GB";
} else {
if (value === 0) return "0 B";
if (value < 1024) return value + " B";
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + " KB";
if (value < 1024 * 1024 * 1024)
return (value / (1024 * 1024)).toFixed(2) + " MB";
return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
};
const formatFlow = formatTraffic;
const formatNumber = (value: number): string => {
// 99999 表示无限制
@@ -279,10 +267,10 @@ export default function DashboardPage() {
const calculateUsagePercentage = (type: "flow" | "forwards"): number => {
if (type === "flow") {
const totalUsed = calculateUserTotalUsedFlow();
const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024;
const totalLimit = flowLimitBytes(userInfo.flow || 0, userInfo.flowMiB);
// 无限制时返回0%
if (userInfo.flow === 99999) return 0;
if (userInfo.flow === 99999 && !userInfo.flowMiB) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
} else if (type === "forwards") {
@@ -351,10 +339,10 @@ export default function DashboardPage() {
const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => {
const totalUsed = calculateTunnelUsedFlow(tunnel);
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
const totalLimit = flowLimitBytes(tunnel.flow || 0, tunnel.flowMiB);
// 无限制时返回0%
if (tunnel.flow === 99999) return 0;
if (tunnel.flow === 99999 && !tunnel.flowMiB) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
};
@@ -741,7 +729,7 @@ export default function DashboardPage() {
}
iconClassName="bg-blue-100 dark:bg-blue-500/20"
title="总流量"
value={formatFlow(userInfo.flow, "gb")}
value={formatFlowLimit(userInfo.flow, userInfo.flowMiB)}
/>
<MetricCard
@@ -750,11 +738,11 @@ export default function DashboardPage() {
{renderProgressBar(
calculateUsagePercentage("flow"),
"sm",
userInfo.flow === 99999,
userInfo.flow === 99999 && !userInfo.flowMiB,
)}
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-default-500 truncate">
{userInfo.flow === 99999
{userInfo.flow === 99999 && !userInfo.flowMiB
? "无限制"
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
</p>
@@ -981,7 +969,7 @@ export default function DashboardPage() {
流量配额
</p>
<p className="font-semibold text-foreground">
{formatFlow(tunnel.flow, "gb")}
{formatFlowLimit(tunnel.flow, tunnel.flowMiB)}
</p>
</div>
<div>
@@ -995,7 +983,7 @@ export default function DashboardPage() {
{renderProgressBar(
calculateTunnelFlowPercentage(tunnel),
"sm",
tunnel.flow === 99999,
tunnel.flow === 99999 && !tunnel.flowMiB,
)}
</div>
</div>
@@ -14,6 +14,7 @@ import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo {
flow: number;
flowMiB?: number;
inFlow: number;
outFlow: number;
num: number;
@@ -26,6 +27,7 @@ export interface DashboardUserTunnel {
tunnelId: number;
tunnelName: string;
flow: number;
flowMiB?: number;
inFlow: number;
outFlow: number;
num: number;
+2 -7
View File
@@ -27,6 +27,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AnimatedPage } from "@/components/animated-page";
import { formatTraffic } from "@/utils/traffic";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -2704,13 +2705,7 @@ export default function ForwardPage() {
// 格式化流量
const formatFlow = (value: number): string => {
if (value === 0) return "0 B";
if (value < 1024) return value + " B";
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + " KB";
if (value < 1024 * 1024 * 1024)
return (value / (1024 * 1024)).toFixed(2) + " MB";
return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB";
return formatTraffic(value);
};
// 显示地址列表弹窗
+2 -9
View File
@@ -20,6 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
import { LayoutGrid, List } from "lucide-react";
import { SearchBar } from "@/components/search-bar";
import { formatTraffic } from "@/utils/traffic";
import { AnimatedPage } from "@/components/animated-page";
import {
Table,
@@ -734,15 +735,7 @@ export default function NodePage() {
// 格式化流量
const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return formatTraffic(bytes);
};
const formatChainType = (chainType: number, hopInx: number) => {
@@ -33,6 +33,7 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import { formatTraffic } from "@/utils/traffic";
import {
DistroIcon,
parseDistroFromVersion,
@@ -137,15 +138,7 @@ const formatDateTime = (ts: number): string => {
});
};
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const formatBytes = formatTraffic;
const formatBytesPerSecond = (bytesPerSecond: number): string => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "0 B/s";
@@ -36,6 +36,7 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import { formatTraffic } from "@/utils/traffic";
import {
getMonitorTunnels,
getTunnelMetrics,
@@ -390,12 +391,7 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
return formatTraffic(n);
};
return (
+71 -39
View File
@@ -5,6 +5,15 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
import { Input } from "@/shadcn-bridge/heroui/input";
import { TrafficLimitField } from "@/components/traffic-limit-field";
import {
formatTraffic,
MIB,
parseTrafficInput,
preferredTrafficUnit,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
import {
Modal,
ModalContent,
@@ -82,6 +91,8 @@ interface RemoteUsageNode {
syncError?: string;
}
const MAX_SAFE_BANDWIDTH_MIB = Math.floor(Number.MAX_SAFE_INTEGER / MIB);
export default function PanelSharingPage() {
const [selectedTab, setSelectedTab] = useState("my-shares");
const [shares, setShares] = useState<PeerShare[]>([]);
@@ -108,6 +119,7 @@ export default function PanelSharingPage() {
allowedDomains: "",
allowedIps: "",
});
const [shareUnit, setShareUnit] = useState<TrafficUnit>("GB");
const [importForm, setImportForm] = useState({
remoteUrl: "",
@@ -124,6 +136,9 @@ export default function PanelSharingPage() {
allowedDomains: "",
allowedIps: "",
});
const [editUnit, setEditUnit] = useState<TrafficUnit>("GB");
const [editOriginalMaxBandwidth, setEditOriginalMaxBandwidth] = useState(0);
const [editBandwidthChanged, setEditBandwidthChanged] = useState(false);
const loadShares = useCallback(async () => {
setLoading(true);
@@ -212,8 +227,17 @@ export default function PanelSharingPage() {
return;
}
if (shareForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数");
const limitMiB =
shareForm.maxBandwidth === 0
? 0
: parseTrafficInput(String(shareForm.maxBandwidth), shareUnit);
if (
limitMiB === null ||
limitMiB > MAX_SAFE_BANDWIDTH_MIB ||
shareForm.maxBandwidth < 0
) {
toast.error("请输入有效的流量上限,0 表示不限流量");
return;
}
@@ -223,7 +247,7 @@ export default function PanelSharingPage() {
const res = await createPeerShare({
name: shareForm.name,
nodeId,
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024,
maxBandwidth: limitMiB * MIB,
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
portRangeStart: shareForm.portRangeStart,
portRangeEnd: shareForm.portRangeEnd,
@@ -274,13 +298,16 @@ export default function PanelSharingPage() {
};
const openEditShare = (share: PeerShare) => {
const mib = share.maxBandwidth / MIB;
const unit = preferredTrafficUnit(mib);
setEditUnit(unit);
setEditOriginalMaxBandwidth(share.maxBandwidth);
setEditBandwidthChanged(false);
setEditForm({
id: share.id,
name: share.name,
maxBandwidth:
share.maxBandwidth > 0
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
: 0,
maxBandwidth: share.maxBandwidth > 0 ? mib / TRAFFIC_UNIT_MIB[unit] : 0,
expiryTime: share.expiryTime,
portRangeStart: share.portRangeStart,
portRangeEnd: share.portRangeEnd,
@@ -296,8 +323,18 @@ export default function PanelSharingPage() {
return;
}
if (editForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数");
const limitMiB =
editForm.maxBandwidth === 0
? 0
: parseTrafficInput(String(editForm.maxBandwidth), editUnit);
if (
editBandwidthChanged &&
(limitMiB === null ||
limitMiB > MAX_SAFE_BANDWIDTH_MIB ||
editForm.maxBandwidth < 0)
) {
toast.error("请输入有效的流量上限,0 表示不限流量");
return;
}
@@ -305,7 +342,9 @@ export default function PanelSharingPage() {
const res = await updatePeerShare({
id: editForm.id,
name: editForm.name,
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
maxBandwidth: editBandwidthChanged
? (limitMiB as number) * MIB
: editOriginalMaxBandwidth,
expiryTime: editForm.expiryTime,
portRangeStart: editForm.portRangeStart,
portRangeEnd: editForm.portRangeEnd,
@@ -362,17 +401,7 @@ export default function PanelSharingPage() {
toast.success("Token已复制");
};
const formatFlowGB = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const formatFlowGB = formatTraffic;
const formatChainType = (chainType: number, hopInx: number) => {
if (chainType === 1) {
@@ -741,17 +770,18 @@ export default function PanelSharingPage() {
})
}
/>
<Input
<TrafficLimitField
description="0 表示不限流量"
label="流量上限 (GB)"
type="number"
label="流量上限"
unit={shareUnit}
value={shareForm.maxBandwidth.toString()}
onChange={(e) =>
setShareForm({
...shareForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
onChange={(value, unit) => {
setShareForm((prev) => ({
...prev,
maxBandwidth: Number(value) || 0,
}));
setShareUnit(unit);
}}
/>
<Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
@@ -826,17 +856,19 @@ export default function PanelSharingPage() {
}
/>
</div>
<Input
<TrafficLimitField
description="0 表示不限流量"
label="流量上限 (GB)"
type="number"
label="流量上限"
unit={editUnit}
value={editForm.maxBandwidth.toString()}
onChange={(e) =>
setEditForm({
...editForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
onChange={(value, unit) => {
setEditForm((prev) => ({
...prev,
maxBandwidth: Number(value) || 0,
}));
setEditUnit(unit);
setEditBandwidthChanged(true);
}}
/>
<Input
description="留空或清除表示永久有效"
+85 -48
View File
@@ -71,23 +71,22 @@ import {
SearchIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { TrafficLimitField } from "@/components/traffic-limit-field";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { removeItemsById, replaceItemById } from "@/utils/list-state";
import {
formatTraffic,
formatFlowLimit,
flowLimitBytes,
flowLimitMiB,
parseTrafficInput,
preferredTrafficUnit,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
if (unit === "gb") {
return `${value} GB`;
} else {
if (value === 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
};
const formatFlow = formatTraffic;
const formatQuotaLimit = (value?: number): string => {
const limit = Number(value ?? 0);
@@ -96,7 +95,14 @@ const formatQuotaLimit = (value?: number): string => {
return "不限";
}
return `${limit} GB`;
return formatTraffic(limit * 1024 ** 3);
};
const trafficInputFor = (flowGB: number, flowMiB?: number) => {
const mib = flowLimitMiB(flowGB, flowMiB);
const unit = preferredTrafficUnit(mib);
return { value: String(mib / TRAFFIC_UNIT_MIB[unit]), unit };
};
const formatDate = (timestamp: number): string => {
@@ -148,6 +154,7 @@ const normalizeUserItem = (item: Partial<User>): User => {
user: String(item.user ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
flowMiB: Number(item.flowMiB ?? 0),
num: Number(item.num ?? 0),
expTime: item.expTime,
flowResetTime: item.flowResetTime ?? 0,
@@ -172,6 +179,7 @@ const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
tunnelName: String(item.tunnelName ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
flowMiB: Number(item.flowMiB ?? 0),
num: Number(item.num ?? 0),
expTime: Number(item.expTime ?? 0),
flowResetTime: Number(item.flowResetTime ?? 0),
@@ -223,6 +231,8 @@ export default function UserPage() {
maxConn: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [userFlowInput, setUserFlowInput] = useState("1000");
const [userFlowUnit, setUserFlowUnit] = useState<TrafficUnit>("GB");
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
const editingUser = useMemo(
@@ -263,6 +273,8 @@ export default function UserPage() {
onClose: onEditTunnelModalClose,
} = useDisclosure();
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
const [tunnelFlowInput, setTunnelFlowInput] = useState("");
const [tunnelFlowUnit, setTunnelFlowUnit] = useState<TrafficUnit>("GB");
const [editTunnelLoading, setEditTunnelLoading] = useState(false);
// 删除确认相关状态
@@ -506,6 +518,8 @@ export default function UserPage() {
const handleAdd = () => {
setIsEdit(false);
setUserFlowInput("1000");
setUserFlowUnit("GB");
setUserForm({
user: "",
pwd: "",
@@ -524,6 +538,10 @@ export default function UserPage() {
const handleEdit = async (user: User) => {
setIsEdit(true);
const trafficInput = trafficInputFor(user.flow, user.flowMiB);
setUserFlowInput(trafficInput.value);
setUserFlowUnit(trafficInput.unit);
let currentGroupIds: number[] = [];
try {
@@ -591,10 +609,21 @@ export default function UserPage() {
return;
}
const flowMiB = parseTrafficInput(userFlowInput, userFlowUnit);
if (flowMiB === null) {
toast.error("请输入有效的流量限制,最小单位为 1 MB");
return;
}
setUserFormLoading(true);
try {
const submitData: any = {
...userForm,
flow: Math.ceil(flowMiB / 1024),
flowMiB:
userFlowInput === "99999" && userFlowUnit === "GB" ? 0 : flowMiB,
expTime: userForm.expTime.getTime(),
groupIds: userForm.groupIds ?? [],
};
@@ -756,6 +785,10 @@ export default function UserPage() {
};
const handleEditTunnel = (userTunnel: UserTunnel) => {
const trafficInput = trafficInputFor(userTunnel.flow, userTunnel.flowMiB);
setTunnelFlowInput(trafficInput.value);
setTunnelFlowUnit(trafficInput.unit);
setEditTunnelForm({
...userTunnel,
speedId: normalizeSpeedId(userTunnel.speedId),
@@ -767,12 +800,24 @@ export default function UserPage() {
const handleUpdateTunnel = async () => {
if (!editTunnelForm) return;
const flowMiB = parseTrafficInput(tunnelFlowInput, tunnelFlowUnit);
if (flowMiB === null) {
toast.error("请输入有效的流量限制,最小单位为 1 MB");
return;
}
const flow = Math.ceil(flowMiB / 1024);
const storedFlowMiB =
tunnelFlowInput === "99999" && tunnelFlowUnit === "GB" ? 0 : flowMiB;
setEditTunnelLoading(true);
try {
const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId);
const response = await updateUserTunnel({
id: editTunnelForm.id,
flow: editTunnelForm.flow,
flow,
flowMiB: storedFlowMiB,
num: editTunnelForm.num,
expTime: editTunnelForm.expTime,
flowResetTime: editTunnelForm.flowResetTime,
@@ -792,6 +837,8 @@ export default function UserPage() {
if (currentUser) {
const nextTunnel = normalizeUserTunnelItem({
...editTunnelForm,
flow,
flowMiB: storedFlowMiB,
speedId: normalizeSpeedId(editTunnelForm.speedId),
speedLimitName:
normalizeSpeedId(editTunnelForm.speedId) !== null
@@ -1148,7 +1195,7 @@ export default function UserPage() {
<div className="flex items-center gap-1 text-xs">
<span className="text-default-500">限制:</span>
<span className="text-default-700 font-medium whitespace-nowrap">
{formatFlow(user.flow, "gb")}
{formatFlowLimit(user.flow, user.flowMiB)}
</span>
</div>
</div>
@@ -1258,9 +1305,9 @@ export default function UserPage() {
: null;
const usedFlow = calculateUserTotalUsedFlow(user);
const flowPercent =
user.flow > 0
user.flow > 0 && !(user.flow === 99999 && !user.flowMiB)
? Math.min(
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
(usedFlow / flowLimitBytes(user.flow, user.flowMiB)) * 100,
100,
)
: 0;
@@ -1316,7 +1363,7 @@ export default function UserPage() {
<div className="flex justify-between text-sm">
<span className="text-default-600">流量限制</span>
<span className="font-medium text-xs">
{formatFlow(user.flow, "gb")}
{formatFlowLimit(user.flow, user.flowMiB)}
</span>
</div>
<div className="flex justify-between text-sm">
@@ -1504,20 +1551,14 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, pwd: e.target.value }))
}
/>
<Input
<TrafficLimitField
isRequired
label="流量限制(GB)"
max="99999"
min="1"
type="number"
value={userForm.flow.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 1),
99999,
);
setUserForm((prev) => ({ ...prev, flow: value }));
label="流量限制"
unit={userFlowUnit}
value={userFlowInput}
onChange={(value, unit) => {
setUserFlowInput(value);
setUserFlowUnit(unit);
}}
/>
<Input
@@ -1963,7 +2004,10 @@ export default function UserPage() {
<div className="flex justify-between text-small">
<span className="text-gray-600">限制:</span>
<span className="font-medium">
{formatFlow(userTunnel.flow, "gb")}
{formatFlowLimit(
userTunnel.flow,
userTunnel.flowMiB,
)}
</span>
</div>
<div className="flex justify-between text-small">
@@ -2083,21 +2127,14 @@ export default function UserPage() {
{editTunnelForm && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="流量限制(GB)"
max="99999"
min="1"
type="number"
value={editTunnelForm.flow.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 1),
99999,
);
setEditTunnelForm((prev) =>
prev ? { ...prev, flow: value } : null,
);
<TrafficLimitField
isRequired
label="流量限制"
unit={tunnelFlowUnit}
value={tunnelFlowInput}
onChange={(value, unit) => {
setTunnelFlowInput(value);
setTunnelFlowUnit(unit);
}}
/>
+2
View File
@@ -12,6 +12,7 @@ export interface User {
pwd?: string;
status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB)
flowMiB?: number; // 精确流量限制(MiB),0 表示沿用旧版 GB 字段
num: number; // 转发数量
expTime?: number; // 过期时间戳
flowResetTime?: number; // 流量重置日期(1-31号)
@@ -56,6 +57,7 @@ export interface UserTunnel {
tunnelName: string;
status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB)
flowMiB?: number;
num: number; // 转发数量
expTime: number; // 过期时间戳
flowResetTime: number;
+72
View File
@@ -0,0 +1,72 @@
export type TrafficUnit = "MB" | "GB" | "TB" | "PB";
export const MIB = 1024 * 1024;
export const GIB = 1024 * MIB;
export const TRAFFIC_UNIT_MIB: Record<TrafficUnit, number> = {
MB: 1,
GB: 1024,
TB: 1024 ** 2,
PB: 1024 ** 3,
};
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
export function formatTraffic(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? Math.floor(value) : value.toFixed(2)} ${BYTE_UNITS[unit]}`;
}
export function flowLimitMiB(flowGB: number, flowMiB?: number): number {
return flowMiB && flowMiB > 0 ? flowMiB : flowGB * 1024;
}
export function flowLimitBytes(flowGB: number, flowMiB?: number): number {
return flowLimitMiB(flowGB, flowMiB) * MIB;
}
export function formatFlowLimit(flowGB: number, flowMiB?: number): string {
if (flowGB === 99999 && !flowMiB) return "无限制";
return formatTraffic(flowLimitBytes(flowGB, flowMiB));
}
export function preferredTrafficUnit(mib: number): TrafficUnit {
if (mib <= 0) return "GB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.PB === 0) return "PB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.TB === 0) return "TB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.GB === 0) return "GB";
return "MB";
}
export function parseTrafficInput(
value: string,
unit: TrafficUnit,
): number | null {
const amount = Number(value);
const mib = amount * TRAFFIC_UNIT_MIB[unit];
if (
!value.trim() ||
!Number.isFinite(amount) ||
amount <= 0 ||
!Number.isSafeInteger(mib)
) {
return null;
}
// Backend stores bytes as int64. Keep the converted value within that range.
if (mib > 8_796_093_022_207) return null;
return mib;
}