mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
merge(main): resolve node page conflicts with renewal UX updates
This commit is contained in:
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
|
||||
export const createNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/create", data);
|
||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const getDashboardNodeExpiryList = () =>
|
||||
Network.post<NodeApiItem[]>("/node/list", {});
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
|
||||
@@ -3,6 +3,9 @@ export interface NodeApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
syncError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -44,6 +47,7 @@ export interface ForwardApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelName?: string;
|
||||
tunnelTrafficRatio?: number;
|
||||
inIp?: string;
|
||||
inPort?: number;
|
||||
remoteAddr?: string;
|
||||
@@ -214,6 +218,9 @@ export interface NodeMutationPayload {
|
||||
name?: string;
|
||||
status?: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
|
||||
@@ -14,9 +14,15 @@ import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||
import {
|
||||
formatNodeRenewalTime,
|
||||
getNodeRenewalCycleLabel,
|
||||
getNodeRenewalSnapshot,
|
||||
} from "@/pages/node/renewal";
|
||||
import {
|
||||
useDashboardData,
|
||||
type DashboardForward as Forward,
|
||||
type DashboardNodeExpiryItem,
|
||||
type DashboardUserTunnel as UserTunnel,
|
||||
} from "@/pages/dashboard/use-dashboard-data";
|
||||
|
||||
@@ -34,6 +40,7 @@ export default function DashboardPage() {
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
} = useDashboardData();
|
||||
@@ -70,6 +77,99 @@ export default function DashboardPage() {
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
const getNodeExpiryStatus = (
|
||||
nextDueTime?: number,
|
||||
renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset",
|
||||
) => {
|
||||
if (!nextDueTime || renewalState === "unset") {
|
||||
return {
|
||||
label: "未设置",
|
||||
badgeClassName:
|
||||
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
|
||||
nextDueTime: undefined as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const diffDays = Math.ceil(
|
||||
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (renewalState === "expired" || diffDays <= 0) {
|
||||
return {
|
||||
label: "已逾期",
|
||||
badgeClassName:
|
||||
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
|
||||
nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return {
|
||||
label: "明天到期",
|
||||
badgeClassName:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
|
||||
nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${diffDays}天后到期`,
|
||||
badgeClassName:
|
||||
diffDays <= 7
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
|
||||
nextDueTime,
|
||||
};
|
||||
};
|
||||
|
||||
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
||||
const renewalSnapshot = getNodeRenewalSnapshot(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
const expiryStatus = getNodeExpiryStatus(
|
||||
renewalSnapshot.nextDueTime,
|
||||
renewalSnapshot.state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||
>
|
||||
{expiryStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-default-500">
|
||||
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||
</div>
|
||||
|
||||
{node.remark?.trim() && (
|
||||
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
|
||||
{node.remark.trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理24小时流量统计数据
|
||||
const processFlowChartData = () => {
|
||||
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
||||
@@ -661,6 +761,54 @@ export default function DashboardPage() {
|
||||
statisticsFlowsCount={statisticsFlows.length}
|
||||
/>
|
||||
|
||||
{isAdmin && nodeExpiryReminders.length > 0 && (
|
||||
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
节点到期提醒
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
展示 7
|
||||
天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
|
||||
{nodeExpiryReminders.length} 个提醒
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
|
||||
</div>
|
||||
{nodeExpiryReminders.length > 6 && (
|
||||
<p className="mt-4 text-xs text-default-500">
|
||||
还有 {nodeExpiryReminders.length - 6}{" "}
|
||||
个节点未展开显示,可前往节点页面继续处理。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
import type { ForwardApiItem, NodeApiItem } from "@/api/types";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getAnnouncement,
|
||||
getDashboardNodeExpiryList,
|
||||
getUserPackageInfo,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { getNodeRenewalSnapshot } from "@/pages/node/renewal";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
export interface DashboardUserInfo {
|
||||
@@ -52,12 +54,46 @@ export interface DashboardStatisticsFlow {
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface DashboardNodeExpiryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
}
|
||||
|
||||
const normalizeDashboardRenewalCycle = (
|
||||
value: unknown,
|
||||
): DashboardNodeExpiryItem["renewalCycle"] => {
|
||||
return value === "month" || value === "quarter" || value === "year"
|
||||
? value
|
||||
: "";
|
||||
};
|
||||
|
||||
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
||||
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
||||
"dashboard:last-expiration-notification";
|
||||
|
||||
const buildExpirationNotificationKey = (
|
||||
userInfo: DashboardUserInfo,
|
||||
tunnels: DashboardUserTunnel[],
|
||||
) => {
|
||||
const userExpTime = userInfo.expTime ?? "permanent";
|
||||
const tunnelExpirationKey = [...tunnels]
|
||||
.map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;
|
||||
};
|
||||
|
||||
interface DashboardDataState {
|
||||
loading: boolean;
|
||||
userInfo: DashboardUserInfo;
|
||||
userTunnels: DashboardUserTunnel[];
|
||||
forwardList: DashboardForward[];
|
||||
statisticsFlows: DashboardStatisticsFlow[];
|
||||
nodeExpiryReminders: DashboardNodeExpiryItem[];
|
||||
isAdmin: boolean;
|
||||
announcement: AnnouncementData | null;
|
||||
}
|
||||
@@ -66,8 +102,10 @@ const checkExpirationNotifications = (
|
||||
userInfo: DashboardUserInfo,
|
||||
tunnels: DashboardUserTunnel[],
|
||||
) => {
|
||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
||||
const lastNotified = localStorage.getItem("lastNotified");
|
||||
const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);
|
||||
const lastNotified = localStorage.getItem(
|
||||
EXPIRATION_NOTIFICATION_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (lastNotified === notificationKey) {
|
||||
return;
|
||||
@@ -148,7 +186,7 @@ const checkExpirationNotifications = (
|
||||
});
|
||||
|
||||
if (hasNotification) {
|
||||
localStorage.setItem("lastNotified", notificationKey);
|
||||
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +212,42 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||
const now = Date.now();
|
||||
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return (items || [])
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
remark: typeof item.remark === "string" ? item.remark : "",
|
||||
renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle),
|
||||
expiryTime:
|
||||
typeof item.expiryTime === "number" && item.expiryTime > 0
|
||||
? item.expiryTime
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (!item.expiryTime || !item.renewalCycle) return false;
|
||||
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;
|
||||
|
||||
return aDue - bDue;
|
||||
});
|
||||
};
|
||||
|
||||
export const useDashboardData = (): DashboardDataState => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||
@@ -184,64 +258,174 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
|
||||
DashboardNodeExpiryItem[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const packageRequestInFlightRef = useRef(false);
|
||||
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
const applyPackageData = useCallback(
|
||||
(data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadPackageData = useCallback(
|
||||
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||
if (packageRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
packageRequestInFlightRef.current = true;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
applyPackageData(res.data || {});
|
||||
} else if (notifyOnError) {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
if (notifyOnError) {
|
||||
toast.error("获取套餐信息失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
packageRequestInFlightRef.current = false;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyPackageData],
|
||||
);
|
||||
|
||||
const loadAnnouncement = useCallback(async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
} else {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
} catch {
|
||||
if (isMountedRef.current) {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNodeExpiryData = useCallback(async () => {
|
||||
if (nodeExpiryRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeExpiryRequestInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const res = await getDashboardNodeExpiryList();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
nodeExpiryRequestInFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const adminFlag = getAdminFlag();
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
|
||||
void loadPackageData({ notifyOnError: true });
|
||||
void loadAnnouncement();
|
||||
if (adminFlag) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setUserInfo({} as DashboardUserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
setIsAdmin(getAdminFlag());
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}, DASHBOARD_POLL_INTERVAL_MS);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -249,6 +433,7 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
};
|
||||
|
||||
+518
-276
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {
|
||||
toastVariant: "success" | "error";
|
||||
toastMessage: string;
|
||||
shouldRefresh: boolean;
|
||||
progressPercent?: number;
|
||||
progressLabel?: string;
|
||||
closeDeleteModal?: boolean;
|
||||
closeChangeTunnelModal?: boolean;
|
||||
resetTargetTunnel?: boolean;
|
||||
@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||
closeDeleteModal: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (
|
||||
: `成功停用 ${summary.successCount} 项`,
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (
|
||||
return {
|
||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||
closeChangeTunnelModal: true,
|
||||
resetTargetTunnel: true,
|
||||
};
|
||||
|
||||
@@ -61,17 +61,27 @@ import {
|
||||
getRemoteSyncErrorMessage,
|
||||
} from "@/pages/node/display";
|
||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import {
|
||||
getNodeRenewalSnapshot,
|
||||
formatNodeRenewalTime,
|
||||
getNodeRenewalCycleLabel,
|
||||
type NodeRenewalCycle,
|
||||
} from "@/pages/node/renewal";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
inx?: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: NodeRenewalCycle;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
serverIpV4?: string;
|
||||
@@ -106,6 +116,9 @@ interface Node {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
remark: string;
|
||||
expiryTime: number;
|
||||
renewalCycle: NodeRenewalCycle;
|
||||
serverHost: string;
|
||||
serverIpV4: string;
|
||||
serverIpV6: string;
|
||||
@@ -148,6 +161,89 @@ interface RemoteUsageNode {
|
||||
syncError?: string;
|
||||
}
|
||||
|
||||
const EXPIRING_SOON_DAYS = 7;
|
||||
|
||||
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
||||
|
||||
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
||||
|
||||
const getNodeReminderEnabled = (node: Node): boolean => {
|
||||
return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;
|
||||
};
|
||||
|
||||
const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
|
||||
const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);
|
||||
|
||||
if (renewal.state === "unset") {
|
||||
return {
|
||||
state: "permanent" as NodeExpiryState,
|
||||
label: "未设置续费周期",
|
||||
tone: "default" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 3,
|
||||
nextDueTime: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (renewal.state === "expired") {
|
||||
return {
|
||||
state: "expired" as NodeExpiryState,
|
||||
label: "已过期",
|
||||
tone: "danger" as const,
|
||||
accentClassName:
|
||||
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
|
||||
bannerClassName:
|
||||
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 0,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (renewal.state === "dueSoon") {
|
||||
return {
|
||||
state: "expiringSoon" as NodeExpiryState,
|
||||
label: renewal.label,
|
||||
tone: "warning" as const,
|
||||
accentClassName:
|
||||
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
||||
bannerClassName:
|
||||
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 1,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "healthy" as NodeExpiryState,
|
||||
label: renewal.label,
|
||||
tone: "success" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 2,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeNodeRealtimeState = (
|
||||
incomingNode: Node,
|
||||
existingNode?: Node,
|
||||
): Node => {
|
||||
return {
|
||||
...incomingNode,
|
||||
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
|
||||
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
|
||||
upgradeLoading:
|
||||
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
|
||||
rollbackLoading:
|
||||
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
@@ -182,7 +278,8 @@ const SortableItem = ({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-hidden"
|
||||
className="overflow-hidden h-full"
|
||||
{...listeners}
|
||||
>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
@@ -208,7 +305,10 @@ export default function NodePage() {
|
||||
const [remoteUsageMap, setRemoteUsageMap] = useState<
|
||||
Record<number, RemoteUsageNode>
|
||||
>({});
|
||||
const [nodeFilterMode, setNodeFilterMode, resetNodeFilterMode] =
|
||||
useLocalStorageState<NodeFilterMode>("node-expiry-filter-mode", "all");
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -221,6 +321,9 @@ export default function NodePage() {
|
||||
const [form, setForm] = useState<NodeForm>({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
expiryTime: 0,
|
||||
renewalCycle: "",
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -313,8 +416,13 @@ export default function NodePage() {
|
||||
}, []);
|
||||
|
||||
// 加载节点列表
|
||||
const loadNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
|
||||
@@ -332,7 +440,13 @@ export default function NodePage() {
|
||||
copyLoading: false,
|
||||
}));
|
||||
|
||||
setNodeList(nodesData);
|
||||
setNodeList((prev) => {
|
||||
const previousById = new Map(prev.map((node) => [node.id, node]));
|
||||
|
||||
return nodesData.map((node) =>
|
||||
mergeNodeRealtimeState(node, previousById.get(node.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = nodesData.some(
|
||||
@@ -354,12 +468,18 @@ export default function NodePage() {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
if (!silent) {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
if (!silent) {
|
||||
toast.error("网络错误,请重试");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -432,7 +552,7 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
||||
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
|
||||
onMessage: handleWebSocketMessage,
|
||||
});
|
||||
|
||||
@@ -445,6 +565,22 @@ export default function NodePage() {
|
||||
setSelectedIds(new Set());
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usingPollingFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadNodes({ silent: true });
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNodes({ silent: true });
|
||||
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [loadNodes, usingPollingFallback]);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
if (bytesPerSecond === 0) return "0 B/s";
|
||||
@@ -620,6 +756,13 @@ export default function NodePage() {
|
||||
newErrors.name = "节点名称长度不能超过50位";
|
||||
}
|
||||
|
||||
if (
|
||||
(form.renewalCycle && !form.expiryTime) ||
|
||||
(!form.renewalCycle && form.expiryTime)
|
||||
) {
|
||||
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
|
||||
}
|
||||
|
||||
const v4 = form.serverIpV4.trim();
|
||||
const v6 = form.serverIpV6.trim();
|
||||
const host = form.serverHost.trim();
|
||||
@@ -679,6 +822,9 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
remark: node.remark || "",
|
||||
expiryTime: node.expiryTime || 0,
|
||||
renewalCycle: node.renewalCycle || "",
|
||||
serverHost: normalizedHost,
|
||||
serverIpV4: normalizedV4,
|
||||
serverIpV6: normalizedV6,
|
||||
@@ -927,6 +1073,9 @@ export default function NodePage() {
|
||||
const { serverHost, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
remark: form.remark.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
renewalCycle: form.renewalCycle,
|
||||
extraIPs: form.extraIPs,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
@@ -948,6 +1097,9 @@ export default function NodePage() {
|
||||
? {
|
||||
...n,
|
||||
name: form.name,
|
||||
remark: form.remark.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
renewalCycle: form.renewalCycle,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
@@ -984,6 +1136,9 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
expiryTime: 0,
|
||||
renewalCycle: "",
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -1130,11 +1285,39 @@ export default function NodePage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const nodeExpiryStats = useMemo(() => {
|
||||
return nodeList.reduce(
|
||||
(acc, node) => {
|
||||
if (node.isRemote === 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
|
||||
if (meta.state === "expired") acc.expired += 1;
|
||||
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
||||
if (getNodeReminderEnabled(node)) {
|
||||
acc.withExpiry += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||
);
|
||||
}, [nodeList]);
|
||||
|
||||
// 根据排序顺序获取节点列表
|
||||
const sortedNodes = useMemo((): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
|
||||
const sortedByDb = [...nodeList].sort((a, b) => {
|
||||
const expiryDiff =
|
||||
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
|
||||
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
|
||||
|
||||
if (expiryDiff !== 0) {
|
||||
return expiryDiff;
|
||||
}
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -1178,6 +1361,7 @@ export default function NodePage() {
|
||||
return nodes.filter(
|
||||
(node) =>
|
||||
(node.name && node.name.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIp &&
|
||||
node.serverIp.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIpV4 &&
|
||||
@@ -1198,8 +1382,32 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
const filteredLocalNodes = useMemo(
|
||||
() => filterNodesByKeyword(localNodes, localSearchKeyword),
|
||||
[filterNodesByKeyword, localNodes, localSearchKeyword],
|
||||
() => {
|
||||
const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword);
|
||||
|
||||
if (nodeFilterMode === "all") {
|
||||
return keywordFiltered;
|
||||
}
|
||||
|
||||
return keywordFiltered.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
return expiryMeta.state === "expiringSoon";
|
||||
case "expired":
|
||||
return expiryMeta.state === "expired";
|
||||
case "withExpiry":
|
||||
return getNodeReminderEnabled(node);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
[filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode],
|
||||
);
|
||||
|
||||
const filteredRemoteNodes = useMemo(
|
||||
@@ -1219,7 +1427,10 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
const canBatchUpgrade = activeTab === "local";
|
||||
const isSearchFiltered = currentSearchKeyword.trim().length > 0;
|
||||
const canUseExpiryFilter = activeTab === "local";
|
||||
const hasKeywordSearch = currentSearchKeyword.trim().length > 0;
|
||||
const hasActiveFilters = nodeFilterMode !== "all";
|
||||
const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => displayNodes.map((n) => n.id),
|
||||
@@ -1280,7 +1491,7 @@ export default function NodePage() {
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
@@ -1328,6 +1539,44 @@ export default function NodePage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 筛选按钮 */}
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="筛选条件"
|
||||
isDisabled={!canUseExpiryFilter}
|
||||
className={
|
||||
canUseExpiryFilter && nodeFilterMode !== "all"
|
||||
? "bg-primary/20 text-primary relative"
|
||||
: "text-default-600 relative"
|
||||
}
|
||||
color={
|
||||
canUseExpiryFilter && nodeFilterMode !== "all"
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
title={canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"}
|
||||
variant="flat"
|
||||
onPress={() => setIsFilterModalOpen(true)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
{canUseExpiryFilter && nodeFilterMode !== "all" && (
|
||||
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
||||
color="default"
|
||||
@@ -1356,7 +1605,11 @@ export default function NodePage() {
|
||||
className="mb-4"
|
||||
color="warning"
|
||||
description={
|
||||
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
|
||||
wsConnecting
|
||||
? "监控连接中..."
|
||||
: usingPollingFallback
|
||||
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
|
||||
: "监控连接已断开,正在重连..."
|
||||
}
|
||||
variant="flat"
|
||||
/>
|
||||
@@ -1374,7 +1627,7 @@ export default function NodePage() {
|
||||
<PageEmptyState
|
||||
className="h-64"
|
||||
message={
|
||||
isSearchFiltered
|
||||
isDisplayFiltered
|
||||
? activeTab === "remote"
|
||||
? "未找到匹配的远程节点"
|
||||
: "未找到匹配的本地节点"
|
||||
@@ -1393,32 +1646,23 @@ export default function NodePage() {
|
||||
{displayNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null;
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
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">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={() => toggleSelect(node.id)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain whitespace-nowrap [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-default-300/80">
|
||||
<h3 className="inline-block min-w-full pr-2 font-semibold text-foreground text-sm">
|
||||
{node.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 ml-2 whitespace-nowrap">
|
||||
<CardHeader className="pb-3 md:pb-3">
|
||||
<div className="flex justify-between items-start w-full gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
className="cursor-grab active:cursor-grabbing p-2 -ml-2 -mt-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex-shrink-0"
|
||||
{...listeners}
|
||||
style={{ touchAction: "none" }}
|
||||
title="拖拽排序"
|
||||
@@ -1432,9 +1676,21 @@ export default function NodePage() {
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={() => toggleSelect(node.id)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="pt-0.5 font-semibold text-foreground truncate text-sm leading-5">
|
||||
{node.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="ml-2 flex max-w-[58%] flex-wrap items-center justify-end gap-1.5 self-start">
|
||||
{isRemoteNode && (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
className="text-[10px] h-5 px-1 flex-shrink-0"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1450,7 +1706,7 @@ export default function NodePage() {
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className="text-xs shrink-0 whitespace-nowrap"
|
||||
className="text-[10px] h-5 px-1"
|
||||
color={connectionStatusMeta.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1459,11 +1715,24 @@ export default function NodePage() {
|
||||
</Chip>
|
||||
);
|
||||
})()}
|
||||
{node.expiryTime &&
|
||||
node.expiryTime > 0 &&
|
||||
node.renewalCycle && (
|
||||
<Chip
|
||||
className="text-[10px] h-5 px-1 flex-shrink-0"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</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)}
|
||||
@@ -1471,6 +1740,9 @@ export default function NodePage() {
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{node.expiryTime &&
|
||||
node.expiryTime > 0 &&
|
||||
node.renewalCycle && <div className="hidden" />}
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
@@ -1778,73 +2050,83 @@ export default function NodePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
{!isRemoteNode && (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openInstallSelector(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="warning"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.upgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
openUpgradeModal("single", node.id)
|
||||
}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="secondary"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.rollbackLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleRollbackNode(node)}
|
||||
>
|
||||
回退
|
||||
</Button>
|
||||
<div className="mt-auto space-y-3">
|
||||
{node.remark?.trim() && (
|
||||
<div className="rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-2.5 text-xs leading-5 text-default-700 break-all">
|
||||
<div title={node.remark.trim()}>
|
||||
{node.remark.trim()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
|
||||
>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
{!isRemoteNode && (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openInstallSelector(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="warning"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.upgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
openUpgradeModal("single", node.id)
|
||||
}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="secondary"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.rollbackLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleRollbackNode(node)}
|
||||
>
|
||||
回退
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
|
||||
>
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="primary"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
onPress={() => handleDelete(node)}
|
||||
>
|
||||
编辑
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(node)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
@@ -1883,6 +2165,76 @@ export default function NodePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
description="可记录供应商、用途、续费说明等补充信息"
|
||||
label="备注"
|
||||
maxRows={4}
|
||||
minRows={3}
|
||||
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
|
||||
value={form.remark}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, remark: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="续费周期"
|
||||
placeholder="选择续费周期"
|
||||
selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as
|
||||
| NodeRenewalCycle
|
||||
| undefined;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
renewalCycle: selected || "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="month" textValue="month">
|
||||
月付
|
||||
</SelectItem>
|
||||
<SelectItem key="quarter" textValue="quarter">
|
||||
季付
|
||||
</SelectItem>
|
||||
<SelectItem key="year" textValue="year">
|
||||
年付
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
|
||||
errorMessage={errors.expiryTime}
|
||||
isInvalid={!!errors.expiryTime}
|
||||
label="续费基准时间"
|
||||
type="datetime-local"
|
||||
value={
|
||||
form.expiryTime > 0
|
||||
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
color="primary"
|
||||
description="例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。"
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
@@ -2492,6 +2844,66 @@ export default function NodePage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isFilterModalOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
onOpenChange={setIsFilterModalOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
筛选条件
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">按到期状态筛选</p>
|
||||
<Select
|
||||
aria-label="按到期状态筛选"
|
||||
className="w-full"
|
||||
selectedKeys={[nodeFilterMode]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as
|
||||
| NodeFilterMode
|
||||
| undefined;
|
||||
|
||||
setNodeFilterMode(selected || "all");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="all">全部节点</SelectItem>
|
||||
<SelectItem key="expiringSoon">
|
||||
7天内续费 ({nodeExpiryStats.expiringSoon})
|
||||
</SelectItem>
|
||||
<SelectItem key="expired">
|
||||
已逾期 ({nodeExpiryStats.expired})
|
||||
</SelectItem>
|
||||
<SelectItem key="withExpiry">
|
||||
已启用续费提醒 ({nodeExpiryStats.withExpiry})
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="default"
|
||||
variant="flat"
|
||||
onPress={resetNodeFilterMode}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
完成
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
export type NodeRenewalCycle = "" | "month" | "quarter" | "year";
|
||||
|
||||
export interface NodeRenewalSnapshot {
|
||||
cycle: NodeRenewalCycle;
|
||||
anchorTime?: number;
|
||||
nextDueTime?: number;
|
||||
diffDays?: number;
|
||||
state: "unset" | "expired" | "dueSoon" | "scheduled";
|
||||
label: string;
|
||||
}
|
||||
|
||||
const addMonths = (timestamp: number, months: number): number => {
|
||||
const date = new Date(timestamp);
|
||||
const next = new Date(date);
|
||||
|
||||
next.setMonth(next.getMonth() + months);
|
||||
|
||||
return next.getTime();
|
||||
};
|
||||
|
||||
const cycleToMonths = (cycle: NodeRenewalCycle): number => {
|
||||
switch (cycle) {
|
||||
case "month":
|
||||
return 1;
|
||||
case "quarter":
|
||||
return 3;
|
||||
case "year":
|
||||
return 12;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getNodeRenewalCycleLabel = (cycle?: string): string => {
|
||||
switch (cycle) {
|
||||
case "month":
|
||||
return "月付";
|
||||
case "quarter":
|
||||
return "季付";
|
||||
case "year":
|
||||
return "年付";
|
||||
default:
|
||||
return "未设置";
|
||||
}
|
||||
};
|
||||
|
||||
export const getNodeRenewalSnapshot = (
|
||||
anchorTime?: number,
|
||||
cycle?: string,
|
||||
warningDays = 7,
|
||||
): NodeRenewalSnapshot => {
|
||||
const normalizedCycle =
|
||||
cycle === "month" || cycle === "quarter" || cycle === "year" ? cycle : "";
|
||||
|
||||
if (!anchorTime || anchorTime <= 0 || !normalizedCycle) {
|
||||
return {
|
||||
cycle: normalizedCycle,
|
||||
anchorTime: anchorTime && anchorTime > 0 ? anchorTime : undefined,
|
||||
state: "unset",
|
||||
label: "未设置续费周期",
|
||||
};
|
||||
}
|
||||
|
||||
const intervalMonths = cycleToMonths(normalizedCycle);
|
||||
let nextDueTime = anchorTime;
|
||||
|
||||
while (nextDueTime < Date.now()) {
|
||||
const advanced = addMonths(nextDueTime, intervalMonths);
|
||||
|
||||
if (advanced === nextDueTime) {
|
||||
break;
|
||||
}
|
||||
nextDueTime = advanced;
|
||||
}
|
||||
|
||||
const diffDays = Math.ceil(
|
||||
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return {
|
||||
cycle: normalizedCycle,
|
||||
anchorTime,
|
||||
nextDueTime,
|
||||
diffDays,
|
||||
state: "expired",
|
||||
label: "今天到期",
|
||||
};
|
||||
}
|
||||
|
||||
if (diffDays <= warningDays) {
|
||||
return {
|
||||
cycle: normalizedCycle,
|
||||
anchorTime,
|
||||
nextDueTime,
|
||||
diffDays,
|
||||
state: "dueSoon",
|
||||
label: diffDays === 1 ? "明天续费" : `${diffDays}天后续费`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cycle: normalizedCycle,
|
||||
anchorTime,
|
||||
nextDueTime,
|
||||
diffDays,
|
||||
state: "scheduled",
|
||||
label: `${diffDays}天后续费`,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatNodeRenewalTime = (timestamp?: number): string => {
|
||||
if (!timestamp || timestamp <= 0) {
|
||||
return "未设置";
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;
|
||||
const STANDARD_RECONNECT_DELAY_MS = 3000;
|
||||
const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;
|
||||
const FALLBACK_RECONNECT_DELAY_MS = 30000;
|
||||
|
||||
const getRealtimeWsUrl = (): string => {
|
||||
const baseUrl =
|
||||
axios.defaults.baseURL ||
|
||||
@@ -34,14 +39,13 @@ export const useNodeRealtime = ({
|
||||
}: UseNodeRealtimeOptions) => {
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
@@ -58,6 +62,7 @@ export const useNodeRealtime = ({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
setUsingPollingFallback(false);
|
||||
|
||||
if (!websocketRef.current) {
|
||||
return;
|
||||
@@ -103,6 +108,7 @@ export const useNodeRealtime = ({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
setUsingPollingFallback(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
@@ -122,15 +128,27 @@ export const useNodeRealtime = ({
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const exhaustedStandardRetries =
|
||||
reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;
|
||||
|
||||
setUsingPollingFallback(exhaustedStandardRetries);
|
||||
|
||||
const reconnectDelay = exhaustedStandardRetries
|
||||
? FALLBACK_RECONNECT_DELAY_MS
|
||||
: Math.min(
|
||||
STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,
|
||||
MAX_STANDARD_RECONNECT_DELAY_MS,
|
||||
);
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
connect();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
}, reconnectDelay);
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
@@ -153,6 +171,7 @@ export const useNodeRealtime = ({
|
||||
return {
|
||||
wsConnected,
|
||||
wsConnecting,
|
||||
usingPollingFallback,
|
||||
reconnectRealtime: connect,
|
||||
disconnectRealtime: disconnect,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -115,6 +116,29 @@ interface TunnelForm {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface BatchProgressState {
|
||||
active: boolean;
|
||||
label: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
return (items || []).map((tunnel) => ({
|
||||
...tunnel,
|
||||
inx: tunnel.inx ?? 0,
|
||||
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
||||
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
||||
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
|
||||
inIp: tunnel.inIp || "",
|
||||
flow: tunnel.flow ?? 1,
|
||||
trafficRatio: tunnel.trafficRatio ?? 1,
|
||||
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||
createdTime: tunnel.createdTime || "",
|
||||
}));
|
||||
};
|
||||
|
||||
export default function TunnelPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
@@ -203,6 +227,11 @@ export default function TunnelPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
label: "",
|
||||
percent: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -211,60 +240,81 @@ export default function TunnelPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const applyTunnelList = useCallback((items: Tunnel[]) => {
|
||||
setTunnels(items);
|
||||
|
||||
const hasDbOrdering = items.some(
|
||||
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
|
||||
);
|
||||
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...items]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((tunnel) => tunnel.id);
|
||||
|
||||
setTunnelOrder(dbOrder);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTunnelOrder(
|
||||
loadStoredOrder(
|
||||
TUNNEL_ORDER_KEY,
|
||||
items.map((tunnel) => tunnel.id),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tunnelsRes, nodesRes] = await Promise.all([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
||||
...t,
|
||||
inx: t.inx ?? 0,
|
||||
}));
|
||||
|
||||
setTunnels(tunnelsData);
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = tunnelsData.some(
|
||||
(t) => t.inx !== undefined && t.inx !== 0,
|
||||
);
|
||||
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...tunnelsData]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((t) => t.id);
|
||||
|
||||
setTunnelOrder(dbOrder);
|
||||
} else {
|
||||
setTunnelOrder(
|
||||
loadStoredOrder(
|
||||
"tunnel-order",
|
||||
tunnelsData.map((t) => t.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||
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 {
|
||||
if (withLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyTunnelList],
|
||||
);
|
||||
|
||||
const refreshNodes = useCallback(async () => {
|
||||
try {
|
||||
const nodesRes = await getNodeList();
|
||||
|
||||
if (nodesRes.code === 0) {
|
||||
setNodes(nodesRes.data || []);
|
||||
} else {
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([refreshTunnelList(false), refreshNodes()]);
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [refreshNodes, refreshTunnelList]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
@@ -327,7 +377,23 @@ export default function TunnelPage() {
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
setTunnelToDelete(null);
|
||||
loadData();
|
||||
setTunnels((prev) =>
|
||||
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
||||
);
|
||||
setTunnelOrder((prev) => {
|
||||
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 {
|
||||
toast.error(response.msg || "删除失败");
|
||||
}
|
||||
@@ -500,7 +566,7 @@ export default function TunnelPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
@@ -725,7 +791,7 @@ export default function TunnelPage() {
|
||||
|
||||
setTunnelOrder(newOrder);
|
||||
|
||||
saveOrder("tunnel-order", newOrder);
|
||||
saveOrder(TUNNEL_ORDER_KEY, newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -779,6 +845,11 @@ export default function TunnelPage() {
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `正在删除 ${selectedIds.size} 条隧道...`,
|
||||
percent: 30,
|
||||
});
|
||||
try {
|
||||
const res = await batchDeleteTunnels(Array.from(selectedIds));
|
||||
|
||||
@@ -787,21 +858,42 @@ export default function TunnelPage() {
|
||||
|
||||
if (result.failCount === 0) {
|
||||
toast.success(`成功删除 ${result.successCount} 项`);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `删除完成:成功 ${result.successCount} 项`,
|
||||
percent: 100,
|
||||
});
|
||||
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 {
|
||||
toast.error(
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
percent: 75,
|
||||
});
|
||||
await refreshTunnelList(false);
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
setBatchDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||
} finally {
|
||||
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -809,6 +901,11 @@ export default function TunnelPage() {
|
||||
const handleBatchRedeploy = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
|
||||
percent: 30,
|
||||
});
|
||||
try {
|
||||
const res = await batchRedeployTunnels(Array.from(selectedIds));
|
||||
|
||||
@@ -824,13 +921,19 @@ export default function TunnelPage() {
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
loadData();
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
percent: 100,
|
||||
});
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(res.msg || "下发失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "下发失败"));
|
||||
} finally {
|
||||
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1038,6 +1141,23 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchProgress.active && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
color="primary"
|
||||
description={batchProgress.label}
|
||||
variant="flat"
|
||||
/>
|
||||
<Progress
|
||||
aria-label={batchProgress.label}
|
||||
className="mt-3"
|
||||
color="primary"
|
||||
size="sm"
|
||||
value={batchProgress.percent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
@@ -66,6 +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";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -122,6 +123,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
|
||||
return inFlow + outFlow;
|
||||
};
|
||||
|
||||
const USER_SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
const normalizeUserItem = (item: Partial<User>): User => {
|
||||
return {
|
||||
id: Number(item.id ?? 0),
|
||||
name: item.name,
|
||||
user: String(item.user ?? ""),
|
||||
status: Number(item.status ?? 0),
|
||||
flow: Number(item.flow ?? 0),
|
||||
num: Number(item.num ?? 0),
|
||||
expTime: item.expTime,
|
||||
flowResetTime: item.flowResetTime ?? 0,
|
||||
createdTime: item.createdTime,
|
||||
inFlow: Number(item.inFlow ?? 0),
|
||||
outFlow: Number(item.outFlow ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
|
||||
return {
|
||||
id: Number(item.id ?? 0),
|
||||
userId: Number(item.userId ?? 0),
|
||||
tunnelId: Number(item.tunnelId ?? 0),
|
||||
tunnelName: String(item.tunnelName ?? ""),
|
||||
status: Number(item.status ?? 0),
|
||||
flow: Number(item.flow ?? 0),
|
||||
num: Number(item.num ?? 0),
|
||||
expTime: Number(item.expTime ?? 0),
|
||||
flowResetTime: Number(item.flowResetTime ?? 0),
|
||||
speedId: item.speedId ?? null,
|
||||
speedLimitName: item.speedLimitName,
|
||||
inFlow: Number(item.inFlow ?? 0),
|
||||
outFlow: Number(item.outFlow ?? 0),
|
||||
tunnelFlow: item.tunnelFlow,
|
||||
};
|
||||
};
|
||||
|
||||
export default function UserPage() {
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -136,6 +174,7 @@ export default function UserPage() {
|
||||
size: 10,
|
||||
total: 0,
|
||||
});
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 用户表单相关状态
|
||||
const {
|
||||
@@ -259,37 +298,38 @@ export default function UserPage() {
|
||||
return !speedLimitIds.has(speedId);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadTunnels();
|
||||
loadSpeedLimits();
|
||||
loadUserGroups();
|
||||
}, [pagination.current, pagination.size, searchKeyword]);
|
||||
|
||||
// 数据加载函数
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword: searchKeyword,
|
||||
});
|
||||
const loadUsers = useCallback(
|
||||
async (keywordOverride?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
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 || "获取用户列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[pagination.current, pagination.size],
|
||||
);
|
||||
|
||||
const loadTunnels = async () => {
|
||||
const loadTunnels = useCallback(async () => {
|
||||
try {
|
||||
const response = await getTunnelList();
|
||||
|
||||
@@ -297,9 +337,9 @@ export default function UserPage() {
|
||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSpeedLimits = async () => {
|
||||
const loadSpeedLimits = useCallback(async () => {
|
||||
try {
|
||||
const response = await getSpeedLimitList();
|
||||
|
||||
@@ -315,9 +355,9 @@ export default function UserPage() {
|
||||
setSpeedLimits(speedLimitList);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUserGroups = async () => {
|
||||
const loadUserGroups = useCallback(async () => {
|
||||
try {
|
||||
const response = await getUserGroupList();
|
||||
|
||||
@@ -325,15 +365,19 @@ export default function UserPage() {
|
||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUserTunnels = async (userId: number) => {
|
||||
const loadUserTunnels = useCallback(async (userId: number) => {
|
||||
setTunnelListLoading(true);
|
||||
try {
|
||||
const response = await getUserTunnelList({ userId });
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
setUserTunnels(
|
||||
Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserTunnelItem(item))
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
toast.error(response.msg || "获取隧道权限列表失败");
|
||||
}
|
||||
@@ -342,12 +386,53 @@ export default function UserPage() {
|
||||
} finally {
|
||||
setTunnelListLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
void loadSpeedLimits();
|
||||
void loadUserGroups();
|
||||
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
}
|
||||
|
||||
searchDebounceRef.current = setTimeout(() => {
|
||||
setPagination((prev) => {
|
||||
if (prev.current === 1) {
|
||||
void loadUsers(searchKeyword);
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { ...prev, current: 1 };
|
||||
});
|
||||
}, USER_SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadUsers, searchKeyword]);
|
||||
|
||||
// 用户管理操作
|
||||
const handleSearch = () => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = null;
|
||||
}
|
||||
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
loadUsers();
|
||||
void loadUsers(searchKeyword);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -405,9 +490,17 @@ export default function UserPage() {
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("删除成功");
|
||||
loadUsers();
|
||||
onDeleteModalClose();
|
||||
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: Math.max(prev.total - 1, 0),
|
||||
}));
|
||||
setUserToDelete(null);
|
||||
if (currentUser?.id === userToDelete.id) {
|
||||
setCurrentUser(null);
|
||||
setUserTunnels([]);
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "删除失败");
|
||||
}
|
||||
@@ -442,7 +535,26 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||
onUserModalClose();
|
||||
loadUsers();
|
||||
const responseUser = normalizeUserItem((response as any).data || {});
|
||||
|
||||
if (
|
||||
isEdit &&
|
||||
responseUser.id > 0 &&
|
||||
pagination.current === 1 &&
|
||||
!searchKeyword.trim()
|
||||
) {
|
||||
setUsers((prev) => replaceItemById(prev, responseUser));
|
||||
} else if (
|
||||
!isEdit &&
|
||||
responseUser.id > 0 &&
|
||||
pagination.current === 1 &&
|
||||
!searchKeyword.trim()
|
||||
) {
|
||||
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
|
||||
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
|
||||
} else {
|
||||
await loadUsers();
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
@@ -500,7 +612,7 @@ export default function UserPage() {
|
||||
}
|
||||
toast.success(response.msg || "分配成功");
|
||||
setBatchTunnelSelections(new Map());
|
||||
loadUserTunnels(currentUser.id);
|
||||
await loadUserTunnels(currentUser.id);
|
||||
} else {
|
||||
toast.error(response.msg || "分配失败");
|
||||
}
|
||||
@@ -546,7 +658,20 @@ export default function UserPage() {
|
||||
toast.success("更新成功");
|
||||
onEditTunnelModalClose();
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id);
|
||||
const nextTunnel = normalizeUserTunnelItem({
|
||||
...editTunnelForm,
|
||||
speedId: normalizeSpeedId(editTunnelForm.speedId),
|
||||
speedLimitName:
|
||||
normalizeSpeedId(editTunnelForm.speedId) !== null
|
||||
? speedLimits.find(
|
||||
(speedLimit) =>
|
||||
speedLimit.id ===
|
||||
normalizeSpeedId(editTunnelForm.speedId),
|
||||
)?.name
|
||||
: undefined,
|
||||
});
|
||||
|
||||
setUserTunnels((prev) => replaceItemById(prev, nextTunnel));
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "更新失败");
|
||||
@@ -572,7 +697,7 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("删除成功");
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id);
|
||||
setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));
|
||||
}
|
||||
onDeleteTunnelModalClose();
|
||||
setTunnelToDelete(null);
|
||||
@@ -603,8 +728,16 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("流量重置成功");
|
||||
onResetFlowModalClose();
|
||||
const targetUserId = userToReset.id;
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === targetUserId
|
||||
? { ...user, inFlow: 0, outFlow: 0 }
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setUserToReset(null);
|
||||
loadUsers(); // 重新加载用户列表
|
||||
} else {
|
||||
toast.error(response.msg || "重置失败");
|
||||
}
|
||||
@@ -634,10 +767,16 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("隧道流量重置成功");
|
||||
onResetTunnelFlowModalClose();
|
||||
const targetTunnelId = tunnelToReset.id;
|
||||
|
||||
setUserTunnels((prev) =>
|
||||
prev.map((userTunnel) =>
|
||||
userTunnel.id === targetTunnelId
|
||||
? { ...userTunnel, inFlow: 0, outFlow: 0 }
|
||||
: userTunnel,
|
||||
),
|
||||
);
|
||||
setTunnelToReset(null);
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id); // 重新加载隧道权限列表
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "重置失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const removeItemsById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
ids: Iterable<number>,
|
||||
): T[] => {
|
||||
const idSet = new Set(ids);
|
||||
|
||||
return items.filter((item) => !idSet.has(item.id));
|
||||
};
|
||||
|
||||
export const replaceItemById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
nextItem: T,
|
||||
): T[] => {
|
||||
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||
};
|
||||
|
||||
export const upsertItemById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
nextItem: T,
|
||||
options?: { prepend?: boolean },
|
||||
): T[] => {
|
||||
const existingIndex = items.findIndex((item) => item.id === nextItem.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return replaceItemById(items, nextItem);
|
||||
}
|
||||
|
||||
if (options?.prepend) {
|
||||
return [nextItem, ...items];
|
||||
}
|
||||
|
||||
return [...items, nextItem];
|
||||
};
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
readonly VITE_GITHUB_REPO?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user