Keep UI improvements: Modal styles, expiryReminderDismissed, BatchActionResultModal

This commit is contained in:
sagitchu
2026-03-15 22:51:09 +08:00
parent 8f336377f6
commit 4e38b73cac
8 changed files with 304 additions and 120 deletions
+4 -1
View File
@@ -574,8 +574,11 @@ export default function AdminLayout({
</div> </div>
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen} isOpen={isOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
+31 -12
View File
@@ -479,10 +479,10 @@ export default function GroupPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2"> <CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3> <h3 className="text-lg font-semibold">隧道分组</h3>
<Button <Button
color="primary" className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
size="sm" color="primary"
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm" size="sm"
onPress={openCreateTunnelGroup} onPress={openCreateTunnelGroup}
> >
新建 新建
@@ -551,10 +551,10 @@ export default function GroupPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2"> <CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3> <h3 className="text-lg font-semibold">用户分组</h3>
<Button <Button
color="primary" className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
size="sm" color="primary"
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm" size="sm"
onPress={openCreateUserGroup} onPress={openCreateUserGroup}
> >
新建 新建
@@ -701,7 +701,11 @@ export default function GroupPage() {
</CardBody> </CardBody>
</Card> </Card>
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen} isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange} onOpenChange={onTunnelGroupModalChange}
> >
@@ -745,7 +749,14 @@ export default function GroupPage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} isOpen={userGroupModalOpen} onOpenChange={onUserGroupModalChange}> <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent> <ModalContent>
<ModalHeader> <ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"} {editingUserGroup ? "编辑用户分组" : "新建用户分组"}
@@ -786,7 +797,11 @@ export default function GroupPage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen} isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange} onOpenChange={onTunnelAssignModalChange}
> >
@@ -833,7 +848,11 @@ export default function GroupPage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen} isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange} onOpenChange={onUserAssignModalChange}
> >
+8 -2
View File
@@ -339,8 +339,11 @@ export default function LimitPage() {
)} )}
{/* 新增/编辑模态框 */} {/* 新增/编辑模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen} isOpen={modalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -414,8 +417,11 @@ export default function LimitPage() {
</Modal> </Modal>
{/* 删除确认模态框 */} {/* 删除确认模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen} isOpen={deleteModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
+232 -100
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -53,6 +52,7 @@ import {
getNodeReleases, getNodeReleases,
rollbackNode, rollbackNode,
getPeerRemoteUsageList, getPeerRemoteUsageList,
dismissNodeExpiryReminder,
type ReleaseChannel, type ReleaseChannel,
} from "@/api"; } from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { PageEmptyState, PageLoadingState } from "@/components/page-state";
@@ -82,6 +82,7 @@ interface Node {
remark?: string; remark?: string;
expiryTime?: number; expiryTime?: number;
renewalCycle?: NodeRenewalCycle; renewalCycle?: NodeRenewalCycle;
expiryReminderDismissed?: number;
ip: string; ip: string;
serverIp: string; serverIp: string;
serverIpV4?: string; serverIpV4?: string;
@@ -274,11 +275,7 @@ const SortableItem = ({
}; };
return ( return (
<div <div ref={setNodeRef} className="overflow-visible h-full" style={style}>
ref={setNodeRef}
style={style}
className="overflow-visible h-full"
>
{children(listeners, attributes)} {children(listeners, attributes)}
</div> </div>
); );
@@ -908,6 +905,24 @@ 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) => { const openInstallSelector = (node: Node) => {
setInstallTargetNode(node); setInstallTargetNode(node);
setInstallChannel("stable"); setInstallChannel("stable");
@@ -1060,7 +1075,9 @@ export default function NodePage() {
); );
if (res.code === 0) { if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); toast.success(
`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`,
);
} else { } else {
toast.error(res.msg || "批量升级失败"); toast.error(res.msg || "批量升级失败");
} }
@@ -1081,12 +1098,14 @@ export default function NodePage() {
const confirmRollback = async () => { const confirmRollback = async () => {
if (!nodeToRollback) return; if (!nodeToRollback) return;
const node = nodeToRollback; const node = nodeToRollback;
setRollbackModalOpen(false); setRollbackModalOpen(false);
setNodeList((prev) => setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
); );
try { try {
const res = await rollbackNode(node.id); const res = await rollbackNode(node.id);
if (res.code === 0) { if (res.code === 0) {
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
} else { } else {
@@ -1224,6 +1243,7 @@ export default function NodePage() {
} }
const nextId = reorderedDisplayIds[reorderedDisplayIndex]; const nextId = reorderedDisplayIds[reorderedDisplayIndex];
reorderedDisplayIndex += 1; reorderedDisplayIndex += 1;
return nextId; return nextId;
@@ -1403,7 +1423,8 @@ export default function NodePage() {
return nodes.filter( return nodes.filter(
(node) => (node) =>
(node.name && node.name.toLowerCase().includes(normalizedKeyword)) || (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 &&
node.serverIp.toLowerCase().includes(normalizedKeyword)) || node.serverIp.toLowerCase().includes(normalizedKeyword)) ||
(node.serverIpV4 && (node.serverIpV4 &&
@@ -1423,34 +1444,31 @@ export default function NodePage() {
[sortedNodes], [sortedNodes],
); );
const filteredLocalNodes = useMemo( const filteredLocalNodes = useMemo(() => {
() => { const keywordFiltered = filterNodesByKeyword(
const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword); localNodes,
localSearchKeyword,
);
if (nodeFilterMode === "all") { if (nodeFilterMode === "all") {
return keywordFiltered; 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;
} }
});
return keywordFiltered.filter((node) => { }, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode]);
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( const filteredRemoteNodes = useMemo(
() => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword),
@@ -1472,7 +1490,8 @@ export default function NodePage() {
const canUseExpiryFilter = activeTab === "local"; const canUseExpiryFilter = activeTab === "local";
const hasKeywordSearch = currentSearchKeyword.trim().length > 0; const hasKeywordSearch = currentSearchKeyword.trim().length > 0;
const hasActiveFilters = nodeFilterMode !== "all"; const hasActiveFilters = nodeFilterMode !== "all";
const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters); const isDisplayFiltered =
hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
const sortableNodeIds = useMemo( const sortableNodeIds = useMemo(
() => displayNodes.map((n) => n.id), () => displayNodes.map((n) => n.id),
@@ -1491,7 +1510,11 @@ export default function NodePage() {
onPress={() => setActiveTab("local")} onPress={() => setActiveTab("local")}
> >
本地节点 本地节点
<Chip className="ml-1 shrink-0 whitespace-nowrap" size="sm" variant="flat"> <Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{localNodes.length} {localNodes.length}
</Chip> </Chip>
</Button> </Button>
@@ -1503,7 +1526,11 @@ export default function NodePage() {
onPress={() => setActiveTab("remote")} onPress={() => setActiveTab("remote")}
> >
远程节点 远程节点
<Chip className="ml-1 shrink-0 whitespace-nowrap" size="sm" variant="flat"> <Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{remoteNodes.length} {remoteNodes.length}
</Chip> </Chip>
</Button> </Button>
@@ -1526,13 +1553,16 @@ export default function NodePage() {
onChange={setCurrentSearchKeyword} onChange={setCurrentSearchKeyword}
onClose={() => setIsSearchVisible(false)} onClose={() => setIsSearchVisible(false)}
onOpen={() => { onOpen={() => {
setIsSearchVisible(true); setIsSearchVisible(true);
setTimeout(() => { setTimeout(() => {
// 精准抓取带有“搜索”字样的输入框并强制聚焦 // 精准抓取带有“搜索”字样的输入框并强制聚焦
const searchInput = document.querySelector('input[placeholder*="搜索"]'); const searchInput = document.querySelector(
if (searchInput) (searchInput as HTMLElement).focus(); 'input[placeholder*="搜索"]',
}, 150); // 150ms 刚好是 CSS 展开动画的时间 );
}}
if (searchInput) (searchInput as HTMLElement).focus();
}, 150); // 150ms 刚好是 CSS 展开动画的时间
}}
/> />
</div> </div>
@@ -1592,7 +1622,6 @@ export default function NodePage() {
<Button <Button
isIconOnly isIconOnly
aria-label="筛选条件" aria-label="筛选条件"
isDisabled={!canUseExpiryFilter}
className={ className={
canUseExpiryFilter && nodeFilterMode !== "all" canUseExpiryFilter && nodeFilterMode !== "all"
? "bg-primary/20 text-primary relative" ? "bg-primary/20 text-primary relative"
@@ -1603,8 +1632,11 @@ export default function NodePage() {
? "primary" ? "primary"
: "default" : "default"
} }
isDisabled={!canUseExpiryFilter}
size="sm" size="sm"
title={canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"} title={
canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"
}
variant="flat" variant="flat"
onPress={() => setIsFilterModalOpen(true)} onPress={() => setIsFilterModalOpen(true)}
> >
@@ -1694,7 +1726,9 @@ 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"> <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) => { {displayNodes.map((node) => {
const isRemoteNode = node.isRemote === 1; const isRemoteNode = node.isRemote === 1;
const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null; const remoteUsage = isRemoteNode
? remoteUsageMap[node.id]
: null;
const expiryMeta = getNodeExpiryMeta( const expiryMeta = getNodeExpiryMeta(
node.expiryTime, node.expiryTime,
node.renewalCycle, node.renewalCycle,
@@ -1704,7 +1738,10 @@ export default function NodePage() {
); );
const hasRemark = Boolean(node.remark?.trim()); const hasRemark = Boolean(node.remark?.trim());
const hasExpiryInfo = Boolean( const hasExpiryInfo = Boolean(
node.expiryTime && node.expiryTime > 0 && node.renewalCycle, node.expiryTime &&
node.expiryTime > 0 &&
node.renewalCycle &&
!node.expiryReminderDismissed,
); );
const hasInfoTrigger = hasRemark || hasExpiryInfo; const hasInfoTrigger = hasRemark || hasExpiryInfo;
const infoCount = Number(hasExpiryInfo) + Number(hasRemark); const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
@@ -1714,14 +1751,13 @@ export default function NodePage() {
<SortableItem key={node.id} id={node.id}> <SortableItem key={node.id} id={node.id}>
{(listeners, attributes) => ( {(listeners, attributes) => (
<Card <Card
data-node-card="true"
key={node.id} 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}`} 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"> <CardHeader className="pb-3 md:pb-3">
<div className="flex justify-between items-start w-full gap-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="flex items-start gap-2 flex-1 min-w-0">
{selectMode && ( {selectMode && (
<Checkbox <Checkbox
className="mt-0.5" className="mt-0.5"
@@ -1742,9 +1778,23 @@ export default function NodePage() {
</div> </div>
</div> </div>
<div className="ml-2 flex-shrink-0 self-start flex items-center gap-1.5"> <div className="ml-2 flex-shrink-0 self-start flex items-center gap-1.5">
<div
<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> 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"
</div> {...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> </div>
</CardHeader> </CardHeader>
@@ -1836,29 +1886,47 @@ 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="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"> <div className="flex justify-between gap-2">
<span className="text-default-500">远程地址</span> <span className="text-default-500">
远程地址
</span>
<span <span
className="font-mono text-right truncate" 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> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-default-500">共享ID</span> <span className="text-default-500">
<span className="font-mono">#{remoteUsage.shareId}</span> 共享ID
</span>
<span className="font-mono">
#{remoteUsage.shareId}
</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-default-500">流量</span> <span className="text-default-500">
流量
</span>
<span className="font-mono"> <span className="font-mono">
{formatFlow(remoteUsage.currentFlow)} {formatFlow(remoteUsage.currentFlow)}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-default-500">带宽上限</span> <span className="text-default-500">
带宽上限
</span>
<span className="font-mono"> <span className="font-mono">
{remoteUsage.maxBandwidth > 0 {remoteUsage.maxBandwidth > 0
? formatSpeed(remoteUsage.maxBandwidth) ? formatSpeed(
remoteUsage.maxBandwidth,
)
: "不限"} : "不限"}
</span> </span>
</div> </div>
@@ -1866,9 +1934,12 @@ 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="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"> <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"> <span className="font-mono text-default-700 dark:text-default-300">
{remoteUsage.usedPorts.length}/{Math.max( {remoteUsage.usedPorts.length}/
{Math.max(
remoteUsage.portRangeEnd - remoteUsage.portRangeEnd -
remoteUsage.portRangeStart + remoteUsage.portRangeStart +
1, 1,
@@ -1891,14 +1962,18 @@ export default function NodePage() {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-default-400">暂无占用端口</div> <div className="text-default-400">
暂无占用端口
</div>
)} )}
</div> </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="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"> <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"> <span className="font-mono text-default-700 dark:text-default-300">
{remoteUsage.activeBindingNum} {remoteUsage.activeBindingNum}
</span> </span>
@@ -1922,13 +1997,22 @@ export default function NodePage() {
</span> </span>
</div> </div>
<div className="mt-1 text-[11px] text-default-500 flex items-center justify-between gap-2"> <div className="mt-1 text-[11px] text-default-500 flex items-center justify-between gap-2">
<span>{formatChainType(binding.chainType, binding.hopInx)}</span> <span>
<span className="font-mono">端口 {binding.allocatedPort}</span> {formatChainType(
binding.chainType,
binding.hopInx,
)}
</span>
<span className="font-mono">
端口 {binding.allocatedPort}
</span>
</div> </div>
</div> </div>
)) ))
) : ( ) : (
<div className="text-default-400">暂无绑定明细</div> <div className="text-default-400">
暂无绑定明细
</div>
)} )}
</div> </div>
</div> </div>
@@ -2066,33 +2150,55 @@ export default function NodePage() {
</> </>
)} )}
{hasInfoTrigger && ( {hasInfoTrigger && (
<div className="mt-2 mb-3 space-y-1.5 rounded-lg border border-divider/50 bg-default-50/50 p-2.5"> <div className="mt-2 mb-3 space-y-2 p-2.5">
{hasExpiryInfo && ( {hasExpiryInfo && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="space-y-2">
<span className="text-[11px] font-medium text-default-500 flex-shrink-0"> <div className="flex items-center justify-between">
提醒: <div className="text-[11px] font-medium text-default-500">
</span> 到期提醒
<Chip </div>
className="text-[10px] h-5 px-1 max-w-full flex-shrink-0" <button
color={expiryMeta.tone} className="text-[10px] text-default-400 hover:text-default-600 transition-colors"
size="sm" type="button"
variant="flat" onClick={(e) => {
> e.stopPropagation();
<span className="truncate max-w-[120px] inline-block align-bottom">{expiryMeta.label}</span> handleDismissExpiryReminder(node.id);
</Chip> }}
<span className="text-[11px] text-default-600 truncate flex-1 min-w-0"> >
{formatNodeRenewalTime(expiryMeta.nextDueTime).split(' ')[0]} 关闭提醒
</span> </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> </div>
)} )}
{hasRemark && ( {hasRemark && (
<div className="flex items-start gap-1.5"> <div className="space-y-2">
<span className="text-[11px] font-medium text-default-500 flex-shrink-0"> <div className="text-[11px] font-medium text-default-500">
备注:{node.remark?.trim()} 备注
</span> </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>
@@ -2181,8 +2287,11 @@ export default function NodePage() {
)} )}
{/* 新增/编辑节点对话框 */} {/* 新增/编辑节点对话框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={dialogVisible} isOpen={dialogVisible}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2252,7 +2361,8 @@ export default function NodePage() {
errorMessage={errors.expiryTime} errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime} isInvalid={!!errors.expiryTime}
label="续费基准时间" label="续费基准时间"
type="date" max="9999-12-31" max="9999-12-31"
type="date"
value={ value={
form.expiryTime > 0 form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 10) ? new Date(form.expiryTime).toISOString().slice(0, 10)
@@ -2599,8 +2709,10 @@ export default function NodePage() {
{/* 回退确认模态框 */} {/* 回退确认模态框 */}
<Modal <Modal
classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={rollbackModalOpen} isOpen={rollbackModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2615,7 +2727,9 @@ export default function NodePage() {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<p> <p>
确定要将节点 <strong>&quot;{nodeToRollback?.name}&quot;</strong> 回退到上一个版本吗? 确定要将节点{" "}
<strong>&quot;{nodeToRollback?.name}&quot;</strong>{" "}
回退到上一个版本吗?
</p> </p>
<p className="text-small text-default-500"> <p className="text-small text-default-500">
节点将执行版本回退并自动重启,期间会导致节点短暂离线。 节点将执行版本回退并自动重启,期间会导致节点短暂离线。
@@ -2635,8 +2749,11 @@ export default function NodePage() {
</Modal> </Modal>
{/* 删除确认模态框 */} {/* 删除确认模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen} isOpen={deleteModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2675,8 +2792,11 @@ export default function NodePage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installSelectorOpen} isOpen={installSelectorOpen}
placement="center" placement="center"
size="md" size="md"
@@ -2725,8 +2845,11 @@ export default function NodePage() {
</Modal> </Modal>
{/* 安装命令模态框 */} {/* 安装命令模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installCommandModal} isOpen={installCommandModal}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2779,8 +2902,11 @@ export default function NodePage() {
</Modal> </Modal>
{/* 版本选择升级模态框 */} {/* 版本选择升级模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={upgradeModalOpen} isOpen={upgradeModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2882,8 +3008,11 @@ export default function NodePage() {
</Modal> </Modal>
{/* 批量删除确认模态框 */} {/* 批量删除确认模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen} isOpen={batchDeleteModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -2922,7 +3051,10 @@ export default function NodePage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isFilterModalOpen} isOpen={isFilterModalOpen}
placement="center" placement="center"
size="md" size="md"
+18 -3
View File
@@ -677,7 +677,11 @@ export default function PanelSharingPage() {
</Tabs> </Tabs>
{/* Create Share Modal */} {/* Create Share Modal */}
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={createShareOpen} isOpen={createShareOpen}
scrollBehavior="inside" scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)} onClose={() => setCreateShareOpen(false)}
@@ -784,7 +788,11 @@ export default function PanelSharingPage() {
</Modal> </Modal>
{/* Edit Share Modal */} {/* Edit Share Modal */}
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={editShareOpen} isOpen={editShareOpen}
scrollBehavior="inside" scrollBehavior="inside"
onClose={() => setEditShareOpen(false)} onClose={() => setEditShareOpen(false)}
@@ -883,7 +891,14 @@ export default function PanelSharingPage() {
</Modal> </Modal>
{/* Import Node Modal */} {/* Import Node Modal */}
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}> <Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importNodeOpen}
onClose={() => setImportNodeOpen(false)}
>
<ModalContent> <ModalContent>
<ModalHeader>导入远程节点</ModalHeader> <ModalHeader>导入远程节点</ModalHeader>
<ModalBody> <ModalBody>
+4 -1
View File
@@ -325,8 +325,11 @@ export default function ProfilePage() {
</div> </div>
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen} isOpen={isOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
+1
View File
@@ -3122,6 +3122,7 @@ export default function TunnelPage() {
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true })); setBatchResultModal((prev) => ({ ...prev, open: true }));
return; return;
} }
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
+6 -1
View File
@@ -59,7 +59,12 @@ import {
getUserGroupList, getUserGroupList,
getUserGroups, getUserGroups,
} from "@/api"; } from "@/api";
import { EditIcon, DeleteIcon, SettingsIcon, SearchIcon } from "@/components/icons"; import {
EditIcon,
DeleteIcon,
SettingsIcon,
SearchIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state"; import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { removeItemsById, replaceItemById } from "@/utils/list-state"; import { removeItemsById, replaceItemById } from "@/utils/list-state";