mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 18:06:36 +08:00
2.1.8-beta1.7
This commit is contained in:
+185
-288
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -40,7 +40,6 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
|
||||
import {
|
||||
createNode,
|
||||
getNodeList,
|
||||
@@ -54,7 +53,6 @@ import {
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
getPeerRemoteUsageList,
|
||||
dismissNodeExpiryReminder,
|
||||
type ReleaseChannel,
|
||||
} from "@/api";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
@@ -84,7 +82,6 @@ interface Node {
|
||||
remark?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: NodeRenewalCycle;
|
||||
expiryReminderDismissed?: number;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
serverIpV4?: string;
|
||||
@@ -252,7 +249,7 @@ const SortableItem = ({
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
children: (listeners: any) => any;
|
||||
children: (listeners: any, attributes?: any) => any;
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
@@ -280,11 +277,9 @@ const SortableItem = ({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-visible h-full"
|
||||
{...listeners}
|
||||
>
|
||||
{children(listeners)}
|
||||
{children(listeners, attributes)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -318,6 +313,8 @@ export default function NodePage() {
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rollbackModalOpen, setRollbackModalOpen] = useState(false);
|
||||
const [nodeToRollback, setNodeToRollback] = useState<Node | null>(null);
|
||||
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
|
||||
const [protocolDisabled, setProtocolDisabled] = useState(false);
|
||||
const [protocolDisabledReason, setProtocolDisabledReason] = useState("");
|
||||
@@ -330,7 +327,7 @@ export default function NodePage() {
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
port: "1000-65535",
|
||||
port: "10000-65535",
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
@@ -865,7 +862,7 @@ export default function NodePage() {
|
||||
serverHost: normalizedHost,
|
||||
serverIpV4: normalizedV4,
|
||||
serverIpV6: normalizedV6,
|
||||
port: node.port || "1000-65535",
|
||||
port: node.port || "10000-65535",
|
||||
tcpListenAddr: node.tcpListenAddr || "[::]",
|
||||
udpListenAddr: node.udpListenAddr || "[::]",
|
||||
interfaceName: (node as any).interfaceName || "",
|
||||
@@ -911,24 +908,6 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissExpiryReminder = async (nodeId: number) => {
|
||||
try {
|
||||
const res = await dismissNodeExpiryReminder(nodeId);
|
||||
|
||||
if (res.code === 0) {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === nodeId ? { ...n, expiryReminderDismissed: 1 } : n,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const openInstallSelector = (node: Node) => {
|
||||
setInstallTargetNode(node);
|
||||
setInstallChannel("stable");
|
||||
@@ -1081,9 +1060,7 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(
|
||||
`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`,
|
||||
);
|
||||
toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`);
|
||||
} else {
|
||||
toast.error(res.msg || "批量升级失败");
|
||||
}
|
||||
@@ -1096,13 +1073,20 @@ export default function NodePage() {
|
||||
};
|
||||
|
||||
// 回退节点
|
||||
const handleRollbackNode = async (node: Node) => {
|
||||
const handleRollbackNode = (node: Node) => {
|
||||
setNodeToRollback(node);
|
||||
setRollbackModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmRollback = async () => {
|
||||
if (!nodeToRollback) return;
|
||||
const node = nodeToRollback;
|
||||
setRollbackModalOpen(false);
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
|
||||
);
|
||||
try {
|
||||
const res = await rollbackNode(node.id);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
|
||||
} else {
|
||||
@@ -1116,6 +1100,7 @@ export default function NodePage() {
|
||||
n.id === node.id ? { ...n, rollbackLoading: false } : n,
|
||||
),
|
||||
);
|
||||
setNodeToRollback(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1199,7 +1184,7 @@ export default function NodePage() {
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
port: "1000-65535",
|
||||
port: "10000-65535",
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
@@ -1239,7 +1224,6 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
const nextId = reorderedDisplayIds[reorderedDisplayIndex];
|
||||
|
||||
reorderedDisplayIndex += 1;
|
||||
|
||||
return nextId;
|
||||
@@ -1419,8 +1403,7 @@ export default function NodePage() {
|
||||
return nodes.filter(
|
||||
(node) =>
|
||||
(node.name && node.name.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.remark &&
|
||||
node.remark.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIp &&
|
||||
node.serverIp.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIpV4 &&
|
||||
@@ -1440,31 +1423,34 @@ export default function NodePage() {
|
||||
[sortedNodes],
|
||||
);
|
||||
|
||||
const filteredLocalNodes = useMemo(() => {
|
||||
const keywordFiltered = filterNodesByKeyword(
|
||||
localNodes,
|
||||
localSearchKeyword,
|
||||
);
|
||||
const filteredLocalNodes = useMemo(
|
||||
() => {
|
||||
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;
|
||||
if (nodeFilterMode === "all") {
|
||||
return keywordFiltered;
|
||||
}
|
||||
});
|
||||
}, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode]);
|
||||
|
||||
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(
|
||||
() => filterNodesByKeyword(remoteNodes, remoteSearchKeyword),
|
||||
@@ -1486,8 +1472,7 @@ export default function NodePage() {
|
||||
const canUseExpiryFilter = activeTab === "local";
|
||||
const hasKeywordSearch = currentSearchKeyword.trim().length > 0;
|
||||
const hasActiveFilters = nodeFilterMode !== "all";
|
||||
const isDisplayFiltered =
|
||||
hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
|
||||
const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => displayNodes.map((n) => n.id),
|
||||
@@ -1506,7 +1491,7 @@ export default function NodePage() {
|
||||
onPress={() => setActiveTab("local")}
|
||||
>
|
||||
本地节点
|
||||
<Chip className="ml-1" size="sm" variant="flat">
|
||||
<Chip className="ml-1 shrink-0 whitespace-nowrap" size="sm" variant="flat">
|
||||
{localNodes.length}
|
||||
</Chip>
|
||||
</Button>
|
||||
@@ -1518,7 +1503,7 @@ export default function NodePage() {
|
||||
onPress={() => setActiveTab("remote")}
|
||||
>
|
||||
远程节点
|
||||
<Chip className="ml-1" size="sm" variant="flat">
|
||||
<Chip className="ml-1 shrink-0 whitespace-nowrap" size="sm" variant="flat">
|
||||
{remoteNodes.length}
|
||||
</Chip>
|
||||
</Button>
|
||||
@@ -1540,7 +1525,14 @@ export default function NodePage() {
|
||||
value={currentSearchKeyword}
|
||||
onChange={setCurrentSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
onOpen={() => {
|
||||
setIsSearchVisible(true);
|
||||
setTimeout(() => {
|
||||
// 精准抓取带有“搜索”字样的输入框并强制聚焦
|
||||
const searchInput = document.querySelector('input[placeholder*="搜索"]');
|
||||
if (searchInput) (searchInput as HTMLElement).focus();
|
||||
}, 150); // 150ms 刚好是 CSS 展开动画的时间
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1600,6 +1592,7 @@ export default function NodePage() {
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="筛选条件"
|
||||
isDisabled={!canUseExpiryFilter}
|
||||
className={
|
||||
canUseExpiryFilter && nodeFilterMode !== "all"
|
||||
? "bg-primary/20 text-primary relative"
|
||||
@@ -1610,11 +1603,8 @@ export default function NodePage() {
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
isDisabled={!canUseExpiryFilter}
|
||||
size="sm"
|
||||
title={
|
||||
canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"
|
||||
}
|
||||
title={canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"}
|
||||
variant="flat"
|
||||
onPress={() => setIsFilterModalOpen(true)}
|
||||
>
|
||||
@@ -1704,9 +1694,7 @@ export default function NodePage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{displayNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const remoteUsage = isRemoteNode
|
||||
? remoteUsageMap[node.id]
|
||||
: null;
|
||||
const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null;
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
@@ -1716,10 +1704,7 @@ export default function NodePage() {
|
||||
);
|
||||
const hasRemark = Boolean(node.remark?.trim());
|
||||
const hasExpiryInfo = Boolean(
|
||||
node.expiryTime &&
|
||||
node.expiryTime > 0 &&
|
||||
node.renewalCycle &&
|
||||
!node.expiryReminderDismissed,
|
||||
node.expiryTime && node.expiryTime > 0 && node.renewalCycle,
|
||||
);
|
||||
const hasInfoTrigger = hasRemark || hasExpiryInfo;
|
||||
const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
|
||||
@@ -1727,30 +1712,16 @@ export default function NodePage() {
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
{(listeners, attributes) => (
|
||||
<Card
|
||||
data-node-card="true"
|
||||
key={node.id}
|
||||
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
|
||||
data-node-card="true"
|
||||
>
|
||||
<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 -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="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<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"
|
||||
@@ -1770,108 +1741,10 @@ export default function NodePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0 self-start">
|
||||
{hasInfoTrigger && (
|
||||
<div className="group/info relative">
|
||||
<button
|
||||
aria-label={`查看节点信息,共 ${infoCount} 项`}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border border-divider/80 bg-background/95 text-default-500 shadow-sm transition hover:border-default-300 hover:text-foreground focus-visible:border-default-300 focus-visible:text-foreground focus-visible:outline-none"
|
||||
type="button"
|
||||
onFocus={(event) =>
|
||||
updateInfoPopoverPlacement(
|
||||
node.id,
|
||||
event.currentTarget,
|
||||
)
|
||||
}
|
||||
onMouseEnter={(event) =>
|
||||
updateInfoPopoverPlacement(
|
||||
node.id,
|
||||
event.currentTarget,
|
||||
)
|
||||
}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</svg>
|
||||
{hasRemark && (
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full border border-background bg-default-300 shadow-sm dark:bg-default-500" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`pointer-events-none invisible absolute z-[60] w-72 max-w-[min(18rem,calc(100vw-4rem))] rounded-xl border border-divider/80 bg-background/98 p-3 opacity-0 shadow-xl backdrop-blur transition-all duration-150 group-hover/info:visible group-hover/info:pointer-events-auto group-hover/info:opacity-100 group-focus-within/info:visible group-focus-within/info:pointer-events-auto group-focus-within/info:opacity-100 ${
|
||||
infoPlacement === "bottom"
|
||||
? "right-0 top-[calc(100%+0.75rem)] translate-y-1 group-hover/info:translate-y-0 group-focus-within/info:translate-y-0"
|
||||
: "right-[calc(100%+0.75rem)] top-1/2 -translate-y-1/2 translate-x-1 group-hover/info:translate-x-0 group-focus-within/info:translate-x-0"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{hasExpiryInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
到期提醒
|
||||
</div>
|
||||
<button
|
||||
className="text-[10px] text-default-400 hover:text-default-600 transition-colors"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismissExpiryReminder(
|
||||
node.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
关闭提醒
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<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 className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700">
|
||||
{formatNodeRenewalTime(
|
||||
expiryMeta.nextDueTime,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRemark && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
备注
|
||||
</div>
|
||||
<div
|
||||
className="max-h-32 overflow-y-auto rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700 break-all [scrollbar-width:thin]"
|
||||
title={node.remark?.trim()}
|
||||
>
|
||||
{node.remark?.trim()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0 self-start flex items-center gap-1.5">
|
||||
|
||||
<div className="cursor-grab active:cursor-grabbing p-1 ml-1 shrink-0 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0" {...attributes} {...listeners} style={{ touchAction: "none" }} title="拖拽排序"><svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><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>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1963,47 +1836,29 @@ export default function NodePage() {
|
||||
<>
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5 space-y-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">
|
||||
远程地址
|
||||
</span>
|
||||
<span className="text-default-500">远程地址</span>
|
||||
<span
|
||||
className="font-mono text-right truncate"
|
||||
title={
|
||||
remoteUsage.remoteUrl ||
|
||||
node.remoteUrl ||
|
||||
"-"
|
||||
}
|
||||
title={remoteUsage.remoteUrl || node.remoteUrl || "-"}
|
||||
>
|
||||
{remoteUsage.remoteUrl ||
|
||||
node.remoteUrl ||
|
||||
"-"}
|
||||
{remoteUsage.remoteUrl || node.remoteUrl || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">
|
||||
共享ID
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
#{remoteUsage.shareId}
|
||||
</span>
|
||||
<span className="text-default-500">共享ID</span>
|
||||
<span className="font-mono">#{remoteUsage.shareId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">
|
||||
流量
|
||||
</span>
|
||||
<span className="text-default-500">流量</span>
|
||||
<span className="font-mono">
|
||||
{formatFlow(remoteUsage.currentFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">
|
||||
带宽上限
|
||||
</span>
|
||||
<span className="text-default-500">带宽上限</span>
|
||||
<span className="font-mono">
|
||||
{remoteUsage.maxBandwidth > 0
|
||||
? formatSpeed(
|
||||
remoteUsage.maxBandwidth,
|
||||
)
|
||||
? formatSpeed(remoteUsage.maxBandwidth)
|
||||
: "不限"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2011,12 +1866,9 @@ export default function NodePage() {
|
||||
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-default-500">
|
||||
占用端口
|
||||
</span>
|
||||
<span className="text-default-500">占用端口</span>
|
||||
<span className="font-mono text-default-700 dark:text-default-300">
|
||||
{remoteUsage.usedPorts.length}/
|
||||
{Math.max(
|
||||
{remoteUsage.usedPorts.length}/{Math.max(
|
||||
remoteUsage.portRangeEnd -
|
||||
remoteUsage.portRangeStart +
|
||||
1,
|
||||
@@ -2030,7 +1882,7 @@ export default function NodePage() {
|
||||
{remoteUsage.usedPorts.map((port) => (
|
||||
<Chip
|
||||
key={`${node.id}-port-${port}`}
|
||||
className="font-mono"
|
||||
className="font-mono shrink-0 whitespace-nowrap"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
@@ -2039,18 +1891,14 @@ export default function NodePage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-default-400">
|
||||
暂无占用端口
|
||||
</div>
|
||||
<div className="text-default-400">暂无占用端口</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-default-500">
|
||||
绑定明细
|
||||
</span>
|
||||
<span className="text-default-500">绑定明细</span>
|
||||
<span className="font-mono text-default-700 dark:text-default-300">
|
||||
{remoteUsage.activeBindingNum}
|
||||
</span>
|
||||
@@ -2074,22 +1922,13 @@ export default function NodePage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-default-500 flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{formatChainType(
|
||||
binding.chainType,
|
||||
binding.hopInx,
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
端口 {binding.allocatedPort}
|
||||
</span>
|
||||
<span>{formatChainType(binding.chainType, binding.hopInx)}</span>
|
||||
<span className="font-mono">端口 {binding.allocatedPort}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-default-400">
|
||||
暂无绑定明细
|
||||
</div>
|
||||
<div className="text-default-400">暂无绑定明细</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2227,6 +2066,38 @@ export default function NodePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{hasInfoTrigger && (
|
||||
<div className="mt-2 mb-3 space-y-1.5 rounded-lg border border-divider/50 bg-default-50/50 p-2.5">
|
||||
{hasExpiryInfo && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-default-500 flex-shrink-0">
|
||||
提醒:
|
||||
</span>
|
||||
<Chip
|
||||
className="text-[10px] h-5 px-1 max-w-full flex-shrink-0"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
<span className="truncate max-w-[120px] inline-block align-bottom">{expiryMeta.label}</span>
|
||||
</Chip>
|
||||
<span className="text-[11px] text-default-600 truncate flex-1 min-w-0">
|
||||
{formatNodeRenewalTime(expiryMeta.nextDueTime).split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRemark && (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-[11px] font-medium text-default-500 flex-shrink-0">
|
||||
备注:{node.remark?.trim()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -2310,7 +2181,7 @@ export default function NodePage() {
|
||||
)}
|
||||
|
||||
{/* 新增/编辑节点对话框 */}
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={dialogVisible}
|
||||
placement="center"
|
||||
@@ -2376,37 +2247,26 @@ export default function NodePage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
showMonthAndYearPickers
|
||||
<Input
|
||||
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
|
||||
errorMessage={errors.expiryTime}
|
||||
isInvalid={!!errors.expiryTime}
|
||||
label="续费基准时间"
|
||||
type="date" max="9999-12-31"
|
||||
value={
|
||||
form.expiryTime > 0
|
||||
? (parseDate(
|
||||
new Date(form.expiryTime).toISOString().split("T")[0],
|
||||
) as any)
|
||||
: null
|
||||
? new Date(form.expiryTime).toISOString().slice(0, 10)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
const jsDate = new Date(
|
||||
date.year,
|
||||
date.month - 1,
|
||||
date.day,
|
||||
);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: jsDate.getTime(),
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: 0,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
@@ -2416,11 +2276,11 @@ export default function NodePage() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6 域名"
|
||||
errorMessage={errors.serverHost}
|
||||
isInvalid={!!errors.serverHost}
|
||||
label="服务器域名/主机名"
|
||||
placeholder="例如: node.example.com"
|
||||
label="域名/主机名"
|
||||
placeholder="例如: test.example.com"
|
||||
value={form.serverHost}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
@@ -2430,11 +2290,11 @@ export default function NodePage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
description="双栈节点组隧道时优先使用 IPv4"
|
||||
description="填写一个 IPv4 地址"
|
||||
errorMessage={errors.serverIpV4}
|
||||
isInvalid={!!errors.serverIpV4}
|
||||
label="服务器IPv4"
|
||||
placeholder="例如: 203.0.113.10"
|
||||
label="IPv4 地址"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
value={form.serverIpV4}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
@@ -2443,10 +2303,10 @@ export default function NodePage() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="至少填写一个 IPv4/IPv6/域名"
|
||||
description="填写一个 IPv6 地址"
|
||||
errorMessage={errors.serverIpV6}
|
||||
isInvalid={!!errors.serverIpV6}
|
||||
label="服务器IPv6"
|
||||
label="IPv6 地址"
|
||||
placeholder="例如: 2001:db8::10"
|
||||
value={form.serverIpV6}
|
||||
variant="bordered"
|
||||
@@ -2460,11 +2320,11 @@ export default function NodePage() {
|
||||
classNames={{
|
||||
input: "font-mono",
|
||||
}}
|
||||
description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔"
|
||||
description="支持单个端口(80)、多个端口(80,443)或端口范围(10000-65535),多个可用逗号分隔"
|
||||
errorMessage={errors.port}
|
||||
isInvalid={!!errors.port}
|
||||
label="可用端口"
|
||||
placeholder="例如: 80,443,1000-65535"
|
||||
placeholder="例如: 80,443,10000-65535"
|
||||
value={form.port}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
@@ -2717,7 +2577,7 @@ export default function NodePage() {
|
||||
<Alert
|
||||
className="mt-4"
|
||||
color="primary"
|
||||
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
|
||||
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
@@ -2737,8 +2597,45 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
{/* 回退确认模态框 */}
|
||||
<Modal
|
||||
classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={rollbackModalOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
size="2xl"
|
||||
onOpenChange={setRollbackModalOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">确认回退</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要将节点 <strong>"{nodeToRollback?.name}"</strong> 回退到上一个版本吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500">
|
||||
节点将执行版本回退并自动重启,期间会导致节点短暂离线。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="secondary" onPress={confirmRollback}>
|
||||
确认回退
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={deleteModalOpen}
|
||||
placement="center"
|
||||
@@ -2778,7 +2675,7 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={installSelectorOpen}
|
||||
placement="center"
|
||||
@@ -2828,7 +2725,7 @@ export default function NodePage() {
|
||||
</Modal>
|
||||
|
||||
{/* 安装命令模态框 */}
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={installCommandModal}
|
||||
placement="center"
|
||||
@@ -2882,7 +2779,7 @@ export default function NodePage() {
|
||||
</Modal>
|
||||
|
||||
{/* 版本选择升级模态框 */}
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={upgradeModalOpen}
|
||||
placement="center"
|
||||
@@ -2946,7 +2843,7 @@ export default function NodePage() {
|
||||
: ""}
|
||||
{r.channel === "dev" && (
|
||||
<Chip
|
||||
className="ml-1"
|
||||
className="ml-1 shrink-0 whitespace-nowrap"
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -2985,7 +2882,7 @@ export default function NodePage() {
|
||||
</Modal>
|
||||
|
||||
{/* 批量删除确认模态框 */}
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
backdrop="blur"
|
||||
isOpen={batchDeleteModalOpen}
|
||||
placement="center"
|
||||
@@ -3025,7 +2922,7 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
|
||||
isOpen={isFilterModalOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
|
||||
Reference in New Issue
Block a user