mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 01:06:36 +08:00
refactor(quota): migrate traffic quota from tunnel to user level
- Replace tunnel_quota table with user_quota table - Add user-level daily/monthly quota tracking and enforcement - Update user CRUD to include quota configuration - Migrate backup/restore to use user quota fields - Update frontend API and UI for user quota management
This commit is contained in:
@@ -18,7 +18,7 @@ import type {
|
||||
UserMutationPayload,
|
||||
NodeMutationPayload,
|
||||
TunnelMutationPayload,
|
||||
TunnelQuotaResetPayload,
|
||||
UserQuotaResetPayload,
|
||||
UserTunnelAssignPayload,
|
||||
UserTunnelListQuery,
|
||||
UserTunnelRemovePayload,
|
||||
@@ -118,8 +118,6 @@ export const getTunnelById = (id: number) =>
|
||||
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/update", data);
|
||||
export const resetTunnelQuota = (data: TunnelQuotaResetPayload) =>
|
||||
Network.post("/tunnel/quota/reset", data);
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
@@ -196,6 +194,8 @@ export const updatePassword = (data: UpdatePasswordPayload) =>
|
||||
// 重置流量接口
|
||||
export const resetUserFlow = (data: { id: number; type: number }) =>
|
||||
Network.post("/user/reset", data);
|
||||
export const resetUserQuota = (data: UserQuotaResetPayload) =>
|
||||
Network.post("/user/quota/reset", data);
|
||||
|
||||
export const getUserGroups = (id: number) =>
|
||||
Network.post<number[]>("/user/groups", { id });
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface UserApiItem {
|
||||
flowResetTime?: number;
|
||||
inFlow?: number;
|
||||
outFlow?: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -43,12 +49,6 @@ export interface TunnelApiItem {
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
outNodeId?: TunnelChainNodePayload[];
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
entryNodeId: number;
|
||||
exitNodeId: number;
|
||||
inx?: number;
|
||||
@@ -223,6 +223,8 @@ export interface UserMutationPayload {
|
||||
num?: number;
|
||||
expTime?: number | string;
|
||||
flowResetTime?: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
tunnelFlow?: number;
|
||||
}
|
||||
|
||||
@@ -263,8 +265,6 @@ export interface TunnelMutationPayload {
|
||||
status?: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
@@ -272,8 +272,8 @@ export interface TunnelMutationPayload {
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
}
|
||||
|
||||
export interface TunnelQuotaResetPayload {
|
||||
tunnelId: number;
|
||||
export interface UserQuotaResetPayload {
|
||||
userId: number;
|
||||
scope?: "daily" | "monthly" | "all";
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
updateTunnel,
|
||||
resetTunnelQuota,
|
||||
deleteTunnel,
|
||||
getNodeList,
|
||||
diagnoseTunnel,
|
||||
@@ -88,12 +87,6 @@ interface Tunnel {
|
||||
protocol?: string;
|
||||
flow: number; // 1: 单向, 2: 双向
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
ipPreference?: string;
|
||||
status: number;
|
||||
createdTime: string;
|
||||
@@ -118,8 +111,6 @@ interface TunnelForm {
|
||||
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
||||
flow: number;
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB: number;
|
||||
monthlyQuotaGB: number;
|
||||
inIp: string; // 入口IP
|
||||
ipPreference: string;
|
||||
status: number;
|
||||
@@ -133,32 +124,6 @@ interface BatchProgressState {
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
const value = Number(bytes ?? 0);
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(
|
||||
Math.floor(Math.log(value) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
|
||||
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const formatQuotaLimit = (value?: number) => {
|
||||
const limit = Number(value ?? 0);
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return "不限";
|
||||
}
|
||||
|
||||
return `${limit} GB`;
|
||||
};
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
return (items || []).map((tunnel) => ({
|
||||
...tunnel,
|
||||
@@ -169,12 +134,6 @@ const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
inIp: tunnel.inIp || "",
|
||||
flow: tunnel.flow ?? 1,
|
||||
trafficRatio: tunnel.trafficRatio ?? 1,
|
||||
dailyQuotaGB: tunnel.dailyQuotaGB ?? 0,
|
||||
monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0,
|
||||
dailyUsedBytes: tunnel.dailyUsedBytes ?? 0,
|
||||
monthlyUsedBytes: tunnel.monthlyUsedBytes ?? 0,
|
||||
disabledByQuota: tunnel.disabledByQuota ?? 0,
|
||||
quotaDisabledAt: tunnel.quotaDisabledAt ?? 0,
|
||||
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||
createdTime: tunnel.createdTime || "",
|
||||
}));
|
||||
@@ -197,7 +156,6 @@ export default function TunnelPage() {
|
||||
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
||||
@@ -394,8 +352,6 @@ export default function TunnelPage() {
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
dailyQuotaGB: tunnel.dailyQuotaGB ?? 0,
|
||||
monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
@@ -407,7 +363,7 @@ export default function TunnelPage() {
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
};
|
||||
|
||||
// 删除隧道
|
||||
const handleDelete = (tunnel: Tunnel) => {
|
||||
@@ -453,28 +409,6 @@ export default function TunnelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
|
||||
if (!form.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuotaResetLoading(true);
|
||||
try {
|
||||
const response = await resetTunnelQuota({ tunnelId: form.id, scope });
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("隧道配额已重置");
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(response.msg || "重置隧道配额失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("重置隧道配额失败");
|
||||
} finally {
|
||||
setQuotaResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 隧道类型改变时的处理
|
||||
const handleTypeChange = (type: number) => {
|
||||
setForm((prev) => ({
|
||||
@@ -1429,28 +1363,6 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 mt-2">
|
||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
||||
<div className="text-xs text-default-500">
|
||||
每日配额
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
||||
{formatBytes(tunnel.dailyUsedBytes)} / {formatQuotaLimit(tunnel.dailyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
||||
<div className="text-xs text-default-500">
|
||||
每月配额
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
||||
{formatBytes(tunnel.monthlyUsedBytes)} / {formatQuotaLimit(tunnel.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tunnel.disabledByQuota ? (
|
||||
<Alert color="danger" title="已因流量配额超额自动禁用并暂停相关转发" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mt-3">
|
||||
@@ -1648,111 +1560,6 @@ export default function TunnelPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
errorMessage={errors.dailyQuotaGB}
|
||||
isInvalid={!!errors.dailyQuotaGB}
|
||||
label="每日配额 (GB)"
|
||||
min={0}
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={String(form.dailyQuotaGB ?? 0)}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
dailyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
errorMessage={errors.monthlyQuotaGB}
|
||||
isInvalid={!!errors.monthlyQuotaGB}
|
||||
label="每月配额 (GB)"
|
||||
min={0}
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={String(form.monthlyQuotaGB ?? 0)}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
monthlyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && editingTunnel && (
|
||||
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
当前配额状态
|
||||
</h3>
|
||||
<p className="text-xs text-default-500">
|
||||
按现有计费口径统计,重置后会自动恢复该次配额暂停的转发
|
||||
</p>
|
||||
</div>
|
||||
{editingTunnel.disabledByQuota ? (
|
||||
<Chip color="danger" size="sm" variant="flat">
|
||||
配额已触发禁用
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip color="success" size="sm" variant="flat">
|
||||
配额正常
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每日用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatBytes(editingTunnel.dailyUsedBytes)} / {formatQuotaLimit(editingTunnel.dailyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每月用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatBytes(editingTunnel.monthlyUsedBytes)} / {formatQuotaLimit(editingTunnel.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("daily")}
|
||||
>
|
||||
重置每日配额
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("monthly")}
|
||||
>
|
||||
重置每月配额
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("all")}
|
||||
>
|
||||
全部重置并恢复
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
errorMessage={errors.inIp}
|
||||
|
||||
@@ -8,8 +8,6 @@ interface TunnelFormInput {
|
||||
inNodeId: TunnelChainNode[];
|
||||
outNodeId?: TunnelChainNode[];
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
}
|
||||
|
||||
interface TunnelNodeInput {
|
||||
@@ -26,8 +24,6 @@ export const createTunnelFormDefaults = () => {
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
@@ -64,14 +60,6 @@ export const validateTunnelForm = (
|
||||
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||
}
|
||||
|
||||
if ((form.dailyQuotaGB ?? 0) < 0) {
|
||||
errors.dailyQuotaGB = "每日配额不能小于 0";
|
||||
}
|
||||
|
||||
if ((form.monthlyQuotaGB ?? 0) < 0) {
|
||||
errors.monthlyQuotaGB = "每月配额不能小于 0";
|
||||
}
|
||||
|
||||
if (form.type === 2) {
|
||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||
errors.outNodeId = "请至少选择一个出口节点";
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
updateUserTunnel,
|
||||
getSpeedLimitList,
|
||||
resetUserFlow,
|
||||
resetUserQuota,
|
||||
getUserGroupList,
|
||||
getUserGroups,
|
||||
} from "@/api";
|
||||
@@ -83,6 +84,16 @@ const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuotaLimit = (value?: number): string => {
|
||||
const limit = Number(value ?? 0);
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return "不限";
|
||||
}
|
||||
|
||||
return `${limit} GB`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
@@ -138,6 +149,12 @@ const normalizeUserItem = (item: Partial<User>): User => {
|
||||
createdTime: item.createdTime,
|
||||
inFlow: Number(item.inFlow ?? 0),
|
||||
outFlow: Number(item.outFlow ?? 0),
|
||||
dailyQuotaGB: Number(item.dailyQuotaGB ?? 0),
|
||||
monthlyQuotaGB: Number(item.monthlyQuotaGB ?? 0),
|
||||
dailyUsedBytes: Number(item.dailyUsedBytes ?? 0),
|
||||
monthlyUsedBytes: Number(item.monthlyUsedBytes ?? 0),
|
||||
disabledByQuota: Number(item.disabledByQuota ?? 0),
|
||||
quotaDisabledAt: Number(item.quotaDisabledAt ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -188,11 +205,22 @@ export default function UserPage() {
|
||||
pwd: "",
|
||||
status: 1,
|
||||
flow: 100,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
num: 10,
|
||||
expTime: null,
|
||||
flowResetTime: 0,
|
||||
});
|
||||
const [userFormLoading, setUserFormLoading] = useState(false);
|
||||
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
|
||||
|
||||
const editingUser = useMemo(
|
||||
() =>
|
||||
userForm.id
|
||||
? users.find((item) => item.id === userForm.id) || null
|
||||
: null,
|
||||
[userForm.id, users],
|
||||
);
|
||||
|
||||
// 隧道权限管理相关状态
|
||||
const {
|
||||
@@ -442,6 +470,8 @@ export default function UserPage() {
|
||||
pwd: "",
|
||||
status: 1,
|
||||
flow: 100,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
num: 10,
|
||||
expTime: null,
|
||||
flowResetTime: 0,
|
||||
@@ -469,6 +499,8 @@ export default function UserPage() {
|
||||
pwd: "",
|
||||
status: user.status,
|
||||
flow: user.flow,
|
||||
dailyQuotaGB: user.dailyQuotaGB ?? 0,
|
||||
monthlyQuotaGB: user.monthlyQuotaGB ?? 0,
|
||||
num: user.num,
|
||||
expTime: user.expTime ? new Date(user.expTime) : null,
|
||||
flowResetTime: user.flowResetTime ?? 0,
|
||||
@@ -748,6 +780,29 @@ export default function UserPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
|
||||
const userId = userForm.id;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuotaResetLoading(true);
|
||||
try {
|
||||
const response = await resetUserQuota({ userId, scope });
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("用户配额已重置");
|
||||
await loadUsers(searchKeyword);
|
||||
} else {
|
||||
toast.error(response.msg || "重置用户配额失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("重置用户配额失败");
|
||||
} finally {
|
||||
setQuotaResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 隧道流量重置相关函数
|
||||
const handleResetTunnelFlow = (userTunnel: UserTunnel) => {
|
||||
setTunnelToReset(userTunnel);
|
||||
@@ -946,6 +1001,16 @@ export default function UserPage() {
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
{user.disabledByQuota ? (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
配额超额
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -983,6 +1048,28 @@ export default function UserPage() {
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
{(user.dailyQuotaGB ?? 0) > 0 ||
|
||||
(user.monthlyQuotaGB ?? 0) > 0 ||
|
||||
(user.disabledByQuota ?? 0) > 0 ? (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">每日配额</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /
|
||||
{" "}
|
||||
{formatQuotaLimit(user.dailyQuotaGB)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">每月配额</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(Number(user.monthlyUsedBytes ?? 0))} /
|
||||
{" "}
|
||||
{formatQuotaLimit(user.monthlyQuotaGB)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">规则数量</span>
|
||||
<span className="font-medium text-xs">
|
||||
@@ -1138,6 +1225,38 @@ export default function UserPage() {
|
||||
setUserForm((prev) => ({ ...prev, flow: value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="每日配额(GB)"
|
||||
max="99999"
|
||||
min="0"
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={userForm.dailyQuotaGB.toString()}
|
||||
onChange={(e) => {
|
||||
const value = Math.min(
|
||||
Math.max(Number(e.target.value) || 0, 0),
|
||||
99999,
|
||||
);
|
||||
|
||||
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="每月配额(GB)"
|
||||
max="99999"
|
||||
min="0"
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={userForm.monthlyQuotaGB.toString()}
|
||||
onChange={(e) => {
|
||||
const value = Math.min(
|
||||
Math.max(Number(e.target.value) || 0, 0),
|
||||
99999,
|
||||
);
|
||||
|
||||
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="规则数量"
|
||||
@@ -1210,6 +1329,81 @@ export default function UserPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit &&
|
||||
editingUser &&
|
||||
((editingUser.dailyQuotaGB ?? 0) > 0 ||
|
||||
(editingUser.monthlyQuotaGB ?? 0) > 0 ||
|
||||
(editingUser.disabledByQuota ?? 0) > 0) && (
|
||||
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
当前配额状态
|
||||
</h3>
|
||||
<p className="text-xs text-default-500">
|
||||
配额超额后会自动暂停该用户的转发,重置后可恢复
|
||||
</p>
|
||||
</div>
|
||||
{editingUser.disabledByQuota ? (
|
||||
<Chip color="danger" size="sm" variant="flat">
|
||||
配额已触发禁用
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip color="success" size="sm" variant="flat">
|
||||
配额正常
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每日用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatFlow(Number(editingUser.dailyUsedBytes ?? 0))} /{" "}
|
||||
{formatQuotaLimit(editingUser.dailyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每月用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "}
|
||||
{formatQuotaLimit(editingUser.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("daily")}
|
||||
>
|
||||
重置每日配额
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("monthly")}
|
||||
>
|
||||
重置每月配额
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("all")}
|
||||
>
|
||||
全部重置并恢复
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RadioGroup
|
||||
label="状态"
|
||||
orientation="horizontal"
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface User {
|
||||
createdTime?: number; // 创建时间戳
|
||||
inFlow?: number; // 下载流量(字节)
|
||||
outFlow?: number; // 上传流量(字节)
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
@@ -33,6 +39,8 @@ export interface UserForm {
|
||||
pwd?: string;
|
||||
status: number;
|
||||
flow: number;
|
||||
dailyQuotaGB: number;
|
||||
monthlyQuotaGB: number;
|
||||
num: number;
|
||||
expTime: Date | null;
|
||||
flowResetTime: number;
|
||||
@@ -83,11 +91,6 @@ export interface Tunnel {
|
||||
exitNodeName?: string;
|
||||
status?: number;
|
||||
flow?: number; // 流量计算类型
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
}
|
||||
|
||||
export interface SpeedLimit {
|
||||
|
||||
Reference in New Issue
Block a user