mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 03:06:37 +08:00
merge(main): resolve node page conflicts with renewal UX updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user