mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 17:16:37 +08:00
feat: node management enhancements and UX improvements (#297)
* feat(nodes): add renewal cycle and auto-advance scheduling - Add renewal_cycle field to nodes for tracking paid vs free cycles - Implement auto-advance scheduling when renewal is processed - Add migration test for renewal_cycle column - Update dashboard to show renewal cycle count - Add renewal status display on node detail page * fix(frontend): show rule count in compact view mode for forward pages * style(frontend): format code and improve node card layout Entire-Checkpoint: 08b760b76538 * fix: restore renewal.ts from main to resolve build errors
This commit is contained in:
@@ -90,7 +90,9 @@ export default function DashboardPage() {
|
||||
};
|
||||
}
|
||||
|
||||
const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const diffDays = Math.ceil(
|
||||
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (renewalState === "expired" || diffDays <= 0) {
|
||||
return {
|
||||
@@ -122,6 +124,7 @@ export default function DashboardPage() {
|
||||
|
||||
const parseNodeTags = (tags?: string) => {
|
||||
if (!tags) return [];
|
||||
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
@@ -150,7 +153,9 @@ export default function DashboardPage() {
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{node.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
|
||||
<div className="mt-1 text-xs text-default-500">
|
||||
节点 ID: {node.id}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||
@@ -804,7 +809,8 @@ export default function DashboardPage() {
|
||||
节点到期提醒
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
展示 7
|
||||
天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -819,7 +825,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
{nodeExpiryReminders.length > 6 && (
|
||||
<p className="mt-4 text-xs text-default-500">
|
||||
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||
还有 {nodeExpiryReminders.length - 6}{" "}
|
||||
个节点未展开显示,可前往节点页面继续处理。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
@@ -231,14 +231,20 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (!item.expiryTime || !item.renewalCycle) return false;
|
||||
const snapshot = getNodeRenewalSnapshot(item.expiryTime, item.renewalCycle);
|
||||
const snapshot = getNodeRenewalSnapshot(
|
||||
item.expiryTime,
|
||||
item.renewalCycle,
|
||||
);
|
||||
|
||||
if (!snapshot.nextDueTime) return false;
|
||||
|
||||
return snapshot.nextDueTime <= now + warningWindowMs;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aDue = getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
|
||||
const bDue = getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
|
||||
const aDue =
|
||||
getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
|
||||
const bDue =
|
||||
getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
|
||||
|
||||
return aDue - bDue;
|
||||
});
|
||||
@@ -265,31 +271,34 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
const packageRequestInFlightRef = useRef(false);
|
||||
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||
|
||||
const applyPackageData = useCallback((data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
const applyPackageData = useCallback(
|
||||
(data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
}, []);
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadPackageData = useCallback(
|
||||
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||
|
||||
+280
-249
@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {
|
||||
name: forward.name,
|
||||
tunnelId: forward.tunnelId ?? 0,
|
||||
tunnelName: forward.tunnelName || "",
|
||||
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
|
||||
forward.tunnelTrafficRatio,
|
||||
),
|
||||
tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),
|
||||
inIp: forward.inIp || "",
|
||||
inPort: forward.inPort ?? 0,
|
||||
remoteAddr: forward.remoteAddr || "",
|
||||
@@ -776,7 +774,10 @@ export default function ForwardPage() {
|
||||
currentTunnel.portRangeMin > 0 &&
|
||||
currentTunnel.portRangeMax > 0
|
||||
) {
|
||||
return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };
|
||||
return {
|
||||
min: currentTunnel.portRangeMin,
|
||||
max: currentTunnel.portRangeMax,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -1036,183 +1037,184 @@ export default function ForwardPage() {
|
||||
return Number.isFinite(shareId) && shareId > 0 ? shareId : null;
|
||||
};
|
||||
|
||||
const mergeFederationShareFlow = useCallback(async (
|
||||
forwardsData: Forward[],
|
||||
): Promise<Forward[]> => {
|
||||
if (forwardsData.length === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
try {
|
||||
const [usageRes, localShareRes] = await Promise.all([
|
||||
getPeerRemoteUsageList(),
|
||||
getPeerShareList(),
|
||||
]);
|
||||
|
||||
const flowByShare = new Map<number, number>();
|
||||
const shareIdsByTunnel = new Map<number, Set<number>>();
|
||||
|
||||
if (usageRes.code === 0 && Array.isArray(usageRes.data)) {
|
||||
usageRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.shareId || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
const bindings = Array.isArray(item.bindings)
|
||||
? (item.bindings as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
bindings.forEach((binding) => {
|
||||
const tunnelId = Number(binding.tunnelId || 0);
|
||||
const chainType = Number(binding.chainType || 0);
|
||||
|
||||
if (!Number.isFinite(tunnelId) || tunnelId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(chainType) && chainType !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareSet = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (!shareSet) {
|
||||
shareSet = new Set<number>();
|
||||
shareIdsByTunnel.set(tunnelId, shareSet);
|
||||
}
|
||||
|
||||
shareSet.add(shareId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {
|
||||
localShareRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.id || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (flowByShare.size === 0) {
|
||||
const mergeFederationShareFlow = useCallback(
|
||||
async (forwardsData: Forward[]): Promise<Forward[]> => {
|
||||
if (forwardsData.length === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
const resolveShareIdForForward = (forward: Forward): number | null => {
|
||||
const candidates = new Set<number>();
|
||||
const shareIdFromName = parseShareIdFromTunnelName(
|
||||
forward.tunnelName || "",
|
||||
);
|
||||
try {
|
||||
const [usageRes, localShareRes] = await Promise.all([
|
||||
getPeerRemoteUsageList(),
|
||||
getPeerShareList(),
|
||||
]);
|
||||
|
||||
if (shareIdFromName) {
|
||||
candidates.add(shareIdFromName);
|
||||
}
|
||||
const flowByShare = new Map<number, number>();
|
||||
const shareIdsByTunnel = new Map<number, Set<number>>();
|
||||
|
||||
const tunnelId = Number(forward.tunnelId || 0);
|
||||
const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);
|
||||
if (usageRes.code === 0 && Array.isArray(usageRes.data)) {
|
||||
usageRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.shareId || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
|
||||
if (shareSetByTunnel && shareSetByTunnel.size > 0) {
|
||||
shareSetByTunnel.forEach((shareId) => {
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
candidates.add(shareId);
|
||||
const bindings = Array.isArray(item.bindings)
|
||||
? (item.bindings as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
bindings.forEach((binding) => {
|
||||
const tunnelId = Number(binding.tunnelId || 0);
|
||||
const chainType = Number(binding.chainType || 0);
|
||||
|
||||
if (!Number.isFinite(tunnelId) || tunnelId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(chainType) && chainType !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareSet = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (!shareSet) {
|
||||
shareSet = new Set<number>();
|
||||
shareIdsByTunnel.set(tunnelId, shareSet);
|
||||
}
|
||||
|
||||
shareSet.add(shareId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return null;
|
||||
if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {
|
||||
localShareRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.id || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bestShareId: number | null = null;
|
||||
let bestFlow = 0;
|
||||
if (flowByShare.size === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
candidates.forEach((shareId) => {
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
const resolveShareIdForForward = (forward: Forward): number | null => {
|
||||
const candidates = new Set<number>();
|
||||
const shareIdFromName = parseShareIdFromTunnelName(
|
||||
forward.tunnelName || "",
|
||||
);
|
||||
|
||||
if (shareFlow > bestFlow) {
|
||||
bestFlow = shareFlow;
|
||||
bestShareId = shareId;
|
||||
if (shareIdFromName) {
|
||||
candidates.add(shareIdFromName);
|
||||
}
|
||||
|
||||
const tunnelId = Number(forward.tunnelId || 0);
|
||||
const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (shareSetByTunnel && shareSetByTunnel.size > 0) {
|
||||
shareSetByTunnel.forEach((shareId) => {
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
candidates.add(shareId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestShareId: number | null = null;
|
||||
let bestFlow = 0;
|
||||
|
||||
candidates.forEach((shareId) => {
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
|
||||
if (shareFlow > bestFlow) {
|
||||
bestFlow = shareFlow;
|
||||
bestShareId = shareId;
|
||||
}
|
||||
});
|
||||
|
||||
return bestShareId;
|
||||
};
|
||||
|
||||
const resolvedShareByForwardId = new Map<number, number>();
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolveShareIdForForward(forward);
|
||||
|
||||
if (shareId) {
|
||||
resolvedShareByForwardId.set(forward.id, shareId);
|
||||
}
|
||||
});
|
||||
|
||||
return bestShareId;
|
||||
};
|
||||
const forwardCountByShare = new Map<number, number>();
|
||||
|
||||
const resolvedShareByForwardId = new Map<number, number>();
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolveShareIdForForward(forward);
|
||||
if (!shareId || !flowByShare.has(shareId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
resolvedShareByForwardId.set(forward.id, shareId);
|
||||
}
|
||||
});
|
||||
forwardCountByShare.set(
|
||||
shareId,
|
||||
(forwardCountByShare.get(shareId) || 0) + 1,
|
||||
);
|
||||
});
|
||||
|
||||
const forwardCountByShare = new Map<number, number>();
|
||||
return forwardsData.map((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
if (!shareId) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
if (!shareId || !flowByShare.has(shareId)) {
|
||||
return;
|
||||
}
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
|
||||
forwardCountByShare.set(
|
||||
shareId,
|
||||
(forwardCountByShare.get(shareId) || 0) + 1,
|
||||
);
|
||||
});
|
||||
if (shareFlow <= 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
return forwardsData.map((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
|
||||
if (!shareId) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
if (directFlow > 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
const count = forwardCountByShare.get(shareId) || 1;
|
||||
const estimated = Math.max(1, Math.floor(shareFlow / count));
|
||||
|
||||
if (shareFlow <= 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
|
||||
if (directFlow > 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const count = forwardCountByShare.get(shareId) || 1;
|
||||
const estimated = Math.max(1, Math.floor(shareFlow / count));
|
||||
|
||||
return { ...forward, federationShareFlow: estimated };
|
||||
});
|
||||
} catch {
|
||||
return forwardsData;
|
||||
}
|
||||
}, []);
|
||||
return { ...forward, federationShareFlow: estimated };
|
||||
});
|
||||
} catch {
|
||||
return forwardsData;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getForwardDisplayFlow = (forward: Forward): number => {
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
@@ -1283,45 +1285,48 @@ export default function ForwardPage() {
|
||||
);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = useCallback(async (lod = true) => {
|
||||
setLoading(lod);
|
||||
try {
|
||||
const [tunnelsRes, speedLimitsRes] = await Promise.all([
|
||||
userTunnel(),
|
||||
getSpeedLimitList(),
|
||||
]);
|
||||
const [allTunnelsRes, nodesRes] = await Promise.allSettled([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
const loadData = useCallback(
|
||||
async (lod = true) => {
|
||||
setLoading(lod);
|
||||
try {
|
||||
const [tunnelsRes, speedLimitsRes] = await Promise.all([
|
||||
userTunnel(),
|
||||
getSpeedLimitList(),
|
||||
]);
|
||||
const [allTunnelsRes, nodesRes] = await Promise.allSettled([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
|
||||
await refreshForwardList(false);
|
||||
await refreshForwardList(false);
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (
|
||||
allTunnelsRes.status === "fulfilled" &&
|
||||
allTunnelsRes.value.code === 0
|
||||
) {
|
||||
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
|
||||
}
|
||||
|
||||
if (nodesRes.status === "fulfilled" && nodesRes.value.code === 0) {
|
||||
setNodes((nodesRes.value.data || []) as Node[]);
|
||||
}
|
||||
|
||||
if (speedLimitsRes.code === 0) {
|
||||
setSpeedLimits(speedLimitsRes.data || []);
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (
|
||||
allTunnelsRes.status === "fulfilled" &&
|
||||
allTunnelsRes.value.code === 0
|
||||
) {
|
||||
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
|
||||
}
|
||||
|
||||
if (nodesRes.status === "fulfilled" && nodesRes.value.code === 0) {
|
||||
setNodes((nodesRes.value.data || []) as Node[]);
|
||||
}
|
||||
|
||||
if (speedLimitsRes.code === 0) {
|
||||
setSpeedLimits(speedLimitsRes.data || []);
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshForwardList]);
|
||||
},
|
||||
[refreshForwardList],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -1389,8 +1394,16 @@ export default function ForwardPage() {
|
||||
newErrors.tunnelId = "请选择关联隧道";
|
||||
}
|
||||
|
||||
if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {
|
||||
if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {
|
||||
if (
|
||||
form.inPort !== null &&
|
||||
form.inPort !== undefined &&
|
||||
form.inPort > 0 &&
|
||||
currentTunnelPortRange
|
||||
) {
|
||||
if (
|
||||
form.inPort < currentTunnelPortRange.min ||
|
||||
form.inPort > currentTunnelPortRange.max
|
||||
) {
|
||||
newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;
|
||||
}
|
||||
}
|
||||
@@ -1485,17 +1498,21 @@ export default function ForwardPage() {
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
setForwardToDelete(null);
|
||||
setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));
|
||||
setForwards((prev) =>
|
||||
prev.filter((forward) => forward.id !== forwardToDelete.id),
|
||||
);
|
||||
setForwardOrder((prev) => {
|
||||
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||
|
||||
saveOrder(FORWARD_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(forwardToDelete.id);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -1518,12 +1535,14 @@ export default function ForwardPage() {
|
||||
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||
|
||||
saveOrder(FORWARD_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(forwardToDelete.id);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -1597,6 +1616,7 @@ export default function ForwardPage() {
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: normalizedSpeedId,
|
||||
};
|
||||
|
||||
res = await createForward(createData);
|
||||
}
|
||||
|
||||
@@ -2787,7 +2807,8 @@ export default function ForwardPage() {
|
||||
}
|
||||
|
||||
if (
|
||||
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&
|
||||
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===
|
||||
1 &&
|
||||
normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1
|
||||
) {
|
||||
existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(
|
||||
@@ -4032,66 +4053,76 @@ export default function ForwardPage() {
|
||||
{compactMode ? (
|
||||
viewMode === "grouped" ? (
|
||||
sortedForwards.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1 mb-3">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
全部规则
|
||||
</span>
|
||||
<span className="text-xs text-default-600">
|
||||
{sortedForwards.length} 条规则
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table
|
||||
aria-label="全部规则列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
items={sortedForwards}
|
||||
<Table
|
||||
aria-label="全部规则列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
{(forward) => (
|
||||
<SortableCompactTableRow
|
||||
formatFlow={formatFlow}
|
||||
formatInAddress={formatInAddress}
|
||||
formatRemoteAddress={formatRemoteAddress}
|
||||
forward={forward}
|
||||
getStrategyDisplay={getStrategyDisplay}
|
||||
handleDelete={handleDelete}
|
||||
handleDiagnose={handleDiagnose}
|
||||
handleEdit={handleEdit}
|
||||
handleServiceToggle={handleServiceToggle}
|
||||
hasMultipleAddresses={hasMultipleAddresses}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
showAddressModal={showAddressModal}
|
||||
toggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
items={sortedForwards}
|
||||
>
|
||||
{(forward) => (
|
||||
<SortableCompactTableRow
|
||||
formatFlow={formatFlow}
|
||||
formatInAddress={formatInAddress}
|
||||
formatRemoteAddress={formatRemoteAddress}
|
||||
forward={forward}
|
||||
getStrategyDisplay={getStrategyDisplay}
|
||||
handleDelete={handleDelete}
|
||||
handleDiagnose={handleDiagnose}
|
||||
handleEdit={handleEdit}
|
||||
handleServiceToggle={handleServiceToggle}
|
||||
hasMultipleAddresses={hasMultipleAddresses}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
showAddressModal={showAddressModal}
|
||||
toggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
|
||||
@@ -202,6 +202,7 @@ const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
|
||||
|
||||
const normalizeNodeTags = (tags?: string): string[] => {
|
||||
if (!tags) return [];
|
||||
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
@@ -257,7 +258,8 @@ const SortableItem = ({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-hidden"
|
||||
className="overflow-hidden h-full"
|
||||
{...listeners}
|
||||
>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
@@ -272,10 +274,8 @@ export default function NodePage() {
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||
"node-expiry-filter-mode",
|
||||
"all",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] =
|
||||
useLocalStorageState<NodeFilterMode>("node-expiry-filter-mode", "all");
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
@@ -675,7 +675,10 @@ export default function NodePage() {
|
||||
newErrors.name = "节点名称长度不能超过50位";
|
||||
}
|
||||
|
||||
if ((form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime)) {
|
||||
if (
|
||||
(form.renewalCycle && !form.expiryTime) ||
|
||||
(!form.renewalCycle && form.expiryTime)
|
||||
) {
|
||||
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
|
||||
}
|
||||
|
||||
@@ -1188,6 +1191,7 @@ export default function NodePage() {
|
||||
if (getNodeReminderEnabled(node)) {
|
||||
acc.withExpiry += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||
@@ -1216,7 +1220,10 @@ export default function NodePage() {
|
||||
|
||||
if (nodeFilterMode !== "all") {
|
||||
filteredNodes = filteredNodes.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
@@ -1298,7 +1305,9 @@ export default function NodePage() {
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||
const selected = Array.from(keys)[0] as
|
||||
| NodeFilterMode
|
||||
| undefined;
|
||||
|
||||
setNodeFilterMode(selected || "all");
|
||||
}}
|
||||
@@ -1436,7 +1445,7 @@ export default function NodePage() {
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
@@ -1498,7 +1507,7 @@ export default function NodePage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
@@ -1517,7 +1526,10 @@ export default function NodePage() {
|
||||
{(node.remark?.trim() || node.tags?.trim()) && (
|
||||
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
|
||||
{node.remark?.trim() && (
|
||||
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
|
||||
<div
|
||||
className="text-xs leading-5 text-default-700 line-clamp-1 break-all"
|
||||
title={node.remark.trim()}
|
||||
>
|
||||
{node.remark.trim()}
|
||||
</div>
|
||||
)}
|
||||
@@ -1538,29 +1550,24 @@ export default function NodePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">下次续费</span>
|
||||
<div className="text-right ml-2">
|
||||
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Chip
|
||||
className="text-[11px]"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-default-500">
|
||||
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||
</div>
|
||||
{node.expiryTime &&
|
||||
node.expiryTime > 0 &&
|
||||
node.renewalCycle && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-default-600">
|
||||
下次续费
|
||||
</span>
|
||||
<Chip
|
||||
className="text-[11px]"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
@@ -1758,7 +1765,7 @@ export default function NodePage() {
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1.5 mt-auto">
|
||||
{!isRemoteNode && (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Button
|
||||
@@ -1943,18 +1950,6 @@ export default function NodePage() {
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="留空表示未设置续费提醒;只有同时设置周期和基准时间才会参与提醒与筛选"
|
||||
label="说明"
|
||||
readOnly
|
||||
value={
|
||||
form.renewalCycle && form.expiryTime > 0
|
||||
? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算`
|
||||
: "未启用循环续费提醒"
|
||||
}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
|
||||
@@ -265,27 +265,30 @@ export default function TunnelPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshTunnelList = useCallback(async (withLoading = true) => {
|
||||
if (withLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const tunnelsRes = await getTunnelList();
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取隧道列表失败");
|
||||
} finally {
|
||||
const refreshTunnelList = useCallback(
|
||||
async (withLoading = true) => {
|
||||
if (withLoading) {
|
||||
setLoading(false);
|
||||
setLoading(true);
|
||||
}
|
||||
}
|
||||
}, [applyTunnelList]);
|
||||
|
||||
try {
|
||||
const tunnelsRes = await getTunnelList();
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取隧道列表失败");
|
||||
} finally {
|
||||
if (withLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyTunnelList],
|
||||
);
|
||||
|
||||
const refreshNodes = useCallback(async () => {
|
||||
try {
|
||||
@@ -381,12 +384,14 @@ export default function TunnelPage() {
|
||||
const next = prev.filter((id) => id !== tunnelToDelete.id);
|
||||
|
||||
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(tunnelToDelete.id);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -858,11 +863,14 @@ export default function TunnelPage() {
|
||||
label: `删除完成:成功 ${result.successCount} 项`,
|
||||
percent: 100,
|
||||
});
|
||||
setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));
|
||||
setTunnels((prev) =>
|
||||
prev.filter((tunnel) => !selectedIds.has(tunnel.id)),
|
||||
);
|
||||
setTunnelOrder((prev) => {
|
||||
const next = prev.filter((id) => !selectedIds.has(id));
|
||||
|
||||
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -66,10 +66,7 @@ import {
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import {
|
||||
removeItemsById,
|
||||
replaceItemById,
|
||||
} from "@/utils/list-state";
|
||||
import { removeItemsById, replaceItemById } from "@/utils/list-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -304,30 +301,30 @@ export default function UserPage() {
|
||||
// 数据加载函数
|
||||
const loadUsers = useCallback(
|
||||
async (keywordOverride?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
const nextUsers = Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserItem(item))
|
||||
: [];
|
||||
if (response.code === 0) {
|
||||
const nextUsers = Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserItem(item))
|
||||
: [];
|
||||
|
||||
setUsers(nextUsers);
|
||||
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
setUsers(nextUsers);
|
||||
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[pagination.current, pagination.size],
|
||||
);
|
||||
@@ -411,6 +408,7 @@ export default function UserPage() {
|
||||
setPagination((prev) => {
|
||||
if (prev.current === 1) {
|
||||
void loadUsers(searchKeyword);
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -667,7 +665,8 @@ export default function UserPage() {
|
||||
normalizeSpeedId(editTunnelForm.speedId) !== null
|
||||
? speedLimits.find(
|
||||
(speedLimit) =>
|
||||
speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),
|
||||
speedLimit.id ===
|
||||
normalizeSpeedId(editTunnelForm.speedId),
|
||||
)?.name
|
||||
: undefined,
|
||||
});
|
||||
@@ -733,7 +732,9 @@ export default function UserPage() {
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,
|
||||
user.id === targetUserId
|
||||
? { ...user, inFlow: 0, outFlow: 0 }
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setUserToReset(null);
|
||||
|
||||
Reference in New Issue
Block a user