mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 16:06:36 +08:00
feat: beautify monitor tab and improve user page (#343)
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# 047 - Beautify Monitor Tab
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Redesign the node monitoring cards in the Monitor tab (监控标签) to make them more visually appealing. Incorporate a dynamic, clean layout mimicking the Nezha monitoring dashboard.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Redesign `ServerCard` in `vite-frontend/src/pages/node/monitor-view.tsx` with gradients, neon statuses, clean layout, up/down arrows for speeds.
|
||||||
|
- [x] Update the realtime KPI cards in the Monitor detailed view to have subtle gradients and dynamically colored text.
|
||||||
|
- [x] Fix TypeScript linting issues with `Progress` component colors (`classNames` vs `color`).
|
||||||
@@ -170,141 +170,85 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="group relative shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col cursor-pointer"
|
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
||||||
onClick={onPress}
|
onClick={onPress}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
{/* Dynamic top gradient bar based on status */}
|
||||||
<div className="flex justify-between items-start w-full gap-3">
|
<div className={`absolute top-0 left-0 right-0 h-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<div className="min-w-0 flex-1">
|
{/* Decorative background glow */}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm leading-5">{node.name}</h3>
|
|
||||||
<span
|
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||||
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${isOnline ? "bg-emerald-500" : "bg-rose-500"}`}
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
title={isOnline ? "在线" : "离线"}
|
<div className="relative flex-shrink-0">
|
||||||
/>
|
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
|
||||||
</div>
|
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-default-500 flex-shrink-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<Clock className="w-3 h-3" />
|
<h3 className="font-semibold text-foreground text-sm truncate">{node.name}</h3>
|
||||||
<span className="font-mono text-[11px]">{metric ? formatUptime(metric.uptime) : "-"}</span>
|
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
|
||||||
|
<span className="font-mono">{isOnline ? "在线" : "离线"}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3 flex-1 flex flex-col">
|
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-4 z-10 w-full overflow-hidden">
|
||||||
{/* IP & Version row */}
|
<div className="grid grid-cols-2 gap-5 w-full">
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-1.5 min-w-0">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="text-default-600">版本</span>
|
<span className="text-default-500">CPU</span>
|
||||||
<span className="text-xs font-mono">{metric ? "在线" : "-"}</span>
|
<span className="font-mono font-medium">{isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
color={getColorByUsage(metric?.cpuUsage)}
|
||||||
|
size="sm"
|
||||||
|
value={isOnline && metric ? metric.cpuUsage : 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 min-w-0">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-default-500">内存</span>
|
||||||
|
<span className="font-mono font-medium">{isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
color={getColorByUsage(metric?.memoryUsage)}
|
||||||
|
size="sm"
|
||||||
|
value={isOnline && metric ? metric.memoryUsage : 0}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System metrics */}
|
<div className="flex items-center justify-between p-2.5 rounded-lg bg-default-50 dark:bg-default-100/30 border border-divider mt-1 w-full">
|
||||||
<div className="space-y-3 mb-3">
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="flex items-center gap-1 text-[10px] text-default-500">
|
||||||
<div>
|
<span className="text-primary-500 inline-block font-bold">↑</span>
|
||||||
<div className="flex justify-between text-xs mb-1">
|
<span>上传</span>
|
||||||
<span>CPU</span>
|
|
||||||
<span className="font-mono">{isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
aria-label="CPU使用率"
|
|
||||||
color={getColorByUsage(metric?.cpuUsage)}
|
|
||||||
size="sm"
|
|
||||||
value={isOnline && metric ? metric.cpuUsage : 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span>内存</span>
|
|
||||||
<span className="font-mono">{isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
aria-label="内存使用率"
|
|
||||||
color={getColorByUsage(metric?.memoryUsage)}
|
|
||||||
size="sm"
|
|
||||||
value={isOnline && metric ? metric.memoryUsage : 0}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-mono text-xs font-semibold truncate">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-px h-6 bg-divider mx-2" />
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="flex flex-col gap-0.5 items-end min-w-0 flex-1">
|
||||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
<div className="flex items-center gap-1 text-[10px] text-default-500">
|
||||||
<div className="text-default-600 mb-0.5">上传</div>
|
<span>下载</span>
|
||||||
<div className="font-mono">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</div>
|
<span className="text-success-500 inline-block font-bold">↓</span>
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-600 mb-0.5">下载</div>
|
|
||||||
<div className="font-mono">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-mono text-xs font-semibold truncate">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Traffic stats */}
|
<div className="flex justify-between items-center mt-1 pt-3 border-t border-divider/50 w-full overflow-hidden">
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="flex items-center gap-1.5 text-[11px] text-default-500 min-w-0">
|
||||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
<Clock className="w-3.5 h-3.5 text-default-400 shrink-0" />
|
||||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">↑ 上行流量</div>
|
<span className="font-mono truncate">{metric ? formatUptime(metric.uptime) : "-"}</span>
|
||||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
</div>
|
||||||
{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}
|
<div className="flex items-center gap-1.5 text-[11px] text-default-500 shrink-0 ml-2">
|
||||||
</div>
|
<Activity className="w-3.5 h-3.5 text-default-400" />
|
||||||
</div>
|
<span className="font-mono">Load: {metric ? metric.load1.toFixed(2) : "-"}</span>
|
||||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
|
||||||
<div className="text-success-600 dark:text-success-400 mb-0.5">↓ 下行流量</div>
|
|
||||||
<div className="font-mono text-success-700 dark:text-success-300">
|
|
||||||
{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Extended metrics */}
|
|
||||||
{isOnline && metric && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{metric.diskUsage > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span>磁盘</span>
|
|
||||||
<span className="font-mono">{metric.diskUsage.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
aria-label="磁盘使用率"
|
|
||||||
color={getColorByUsage(metric.diskUsage)}
|
|
||||||
size="sm"
|
|
||||||
value={metric.diskUsage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(metric.load1 > 0 || metric.load5 > 0 || metric.load15 > 0) && (
|
|
||||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
|
||||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-500 text-[10px]">负载 1m</div>
|
|
||||||
<div className="font-mono">{metric.load1.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-500 text-[10px]">负载 5m</div>
|
|
||||||
<div className="font-mono">{metric.load5.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-500 text-[10px]">负载 15m</div>
|
|
||||||
<div className="font-mono">{metric.load15.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(metric.tcpConns > 0 || metric.udpConns > 0) && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-500 text-[10px]">TCP 连接</div>
|
|
||||||
<div className="font-mono">{metric.tcpConns}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
|
||||||
<div className="text-default-500 text-[10px]">UDP 连接</div>
|
|
||||||
<div className="font-mono">{metric.udpConns}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1282,19 +1226,21 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
|
|||||||
|
|
||||||
{/* Realtime KPI cards */}
|
{/* Realtime KPI cards */}
|
||||||
{detailRealtimeMetric && (
|
{detailRealtimeMetric && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pt-2">
|
||||||
{[
|
{[
|
||||||
{ label: "CPU", value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.cpuUsage) },
|
{ label: "CPU", value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.cpuUsage) },
|
||||||
{ label: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) },
|
{ label: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) },
|
||||||
{ label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) },
|
{ label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) },
|
||||||
{ label: "↓ 下行", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const },
|
{ label: "↓ 下行速度", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const },
|
||||||
{ label: "↑ 上行", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const },
|
{ label: "↑ 上行速度", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const },
|
||||||
{ label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const },
|
{ label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Card key={item.label}>
|
<Card key={item.label} className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
<CardBody className="py-3 px-4 flex flex-col items-center">
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
<span className="text-[11px] text-default-500 mb-1">{item.label}</span>
|
<span className="text-[11px] text-default-500 mb-1.5">{item.label}</span>
|
||||||
<span className="text-sm font-semibold font-mono">{item.value}</span>
|
<span className={`text-sm font-semibold font-mono ${item.color === 'danger' ? 'text-danger' : item.color === 'warning' ? 'text-warning' : item.color === 'success' ? 'text-success' : item.color === 'primary' ? 'text-primary' : ''}`}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+186
-174
@@ -245,7 +245,6 @@ export default function UserPage() {
|
|||||||
|
|
||||||
// 分配新隧道权限相关状态
|
// 分配新隧道权限相关状态
|
||||||
const [assignLoading, setAssignLoading] = useState(false);
|
const [assignLoading, setAssignLoading] = useState(false);
|
||||||
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
|
|
||||||
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
|
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
|
||||||
Map<number, number | null>
|
Map<number, number | null>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
@@ -1519,7 +1518,7 @@ export default function UserPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full sm:max-w-xl rounded-2xl",
|
base: "max-w-[95vw] sm:max-w-4xl",
|
||||||
}}
|
}}
|
||||||
isDismissable={false}
|
isDismissable={false}
|
||||||
isOpen={isTunnelModalOpen}
|
isOpen={isTunnelModalOpen}
|
||||||
@@ -1570,147 +1569,136 @@ export default function UserPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 relative">
|
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
|
||||||
<p className="text-base text-default-700 ml-1 font-medium">
|
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
|
||||||
隧道列表
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 顶部触发框 */}
|
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
|
||||||
<div
|
{tunnels.map((tunnel) => {
|
||||||
className={`group flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all cursor-pointer shadow-sm w-[320px] ${isTunnelListExpanded ? "border-primary bg-white ring-2 ring-primary/10 dark:bg-default-100 dark:ring-primary/20" : "border-default-200 bg-default-50 hover:border-primary-300 dark:hover:bg-default-100 dark:hover:border-primary-400"}`}
|
const isAssigned = isTunnelAssigned(tunnel.id);
|
||||||
onClick={() =>
|
const isSelected = batchTunnelSelections.has(tunnel.id);
|
||||||
setIsTunnelListExpanded(!isTunnelListExpanded)
|
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
|
||||||
}
|
tunnel.id,
|
||||||
>
|
);
|
||||||
<span
|
|
||||||
className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}
|
|
||||||
>
|
|
||||||
{batchTunnelSelections.size > 0
|
|
||||||
? `已选 ${batchTunnelSelections.size} 项:` +
|
|
||||||
Array.from(batchTunnelSelections.keys())
|
|
||||||
.map(
|
|
||||||
(id) => tunnels.find((t) => t.id === id)?.name,
|
|
||||||
)
|
|
||||||
.join("、")
|
|
||||||
: "请选择隧道(勾选后配置限速)"}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className={`w-5 h-5 text-default-400 transition-transform ${isTunnelListExpanded ? "rotate-180 text-primary" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M19 9l-7 7-7-7" strokeWidth={2.5} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 列表悬浮层 */}
|
return (
|
||||||
{isTunnelListExpanded && (
|
<div
|
||||||
<div
|
key={tunnel.id}
|
||||||
className="absolute top-[calc(100%+8px)] left-0 z-[999] w-[320px] overflow-hidden rounded-2xl border border-default-200 bg-white text-foreground shadow-2xl dark:bg-default-50"
|
aria-disabled={isAssigned}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className={`
|
||||||
>
|
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
|
||||||
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
|
${
|
||||||
{tunnels.map((tunnel) => {
|
isAssigned
|
||||||
const isAssigned = isTunnelAssigned(tunnel.id);
|
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
||||||
const isSelected = batchTunnelSelections.has(
|
: isSelected
|
||||||
tunnel.id,
|
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||||
);
|
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||||
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
|
}
|
||||||
tunnel.id,
|
`}
|
||||||
);
|
role="button"
|
||||||
const currentSpeedId = batchTunnelSelections.get(
|
tabIndex={isAssigned ? -1 : 0}
|
||||||
tunnel.id,
|
onClick={() =>
|
||||||
);
|
!isAssigned && toggleTunnelSelection(tunnel.id)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isAssigned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
<div
|
event.preventDefault();
|
||||||
key={tunnel.id}
|
toggleTunnelSelection(tunnel.id);
|
||||||
className={`mb-1 flex items-center justify-between rounded-xl border px-4 py-2.5 transition-all ${isSelected ? "border-primary-200 bg-primary-50/60 dark:border-primary-500/40 dark:bg-primary-900/40" : "border-transparent bg-transparent hover:bg-default-100"} ${isAssigned ? "cursor-not-allowed opacity-40 grayscale" : "cursor-pointer"}`}
|
}
|
||||||
// 核心:整行点击直接控制状态
|
}}
|
||||||
onClick={(e) => {
|
>
|
||||||
if (isAssigned) return;
|
<div className="flex items-center justify-between gap-4">
|
||||||
e.stopPropagation();
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
toggleTunnelSelection(tunnel.id);
|
<Checkbox
|
||||||
}}
|
color="primary"
|
||||||
|
isDisabled={isAssigned}
|
||||||
|
isSelected={isSelected}
|
||||||
|
size="md"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
onValueChange={() =>
|
||||||
|
toggleTunnelSelection(tunnel.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-medium truncate ${isAssigned ? "text-default-400" : "text-default-700 dark:text-default-600"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
{tunnel.name}
|
||||||
<Checkbox
|
</span>
|
||||||
color="primary"
|
{isAssigned && (
|
||||||
isSelected={isSelected}
|
<Chip
|
||||||
isDisabled={isAssigned}
|
className="shrink-0"
|
||||||
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
|
color="default"
|
||||||
className="pointer-events-none"
|
size="sm"
|
||||||
/>
|
variant="flat"
|
||||||
<span
|
>
|
||||||
className={`truncate text-sm font-medium text-foreground ${isSelected ? "text-primary-700 dark:text-primary-300" : ""}`}
|
已分配
|
||||||
>
|
</Chip>
|
||||||
{tunnel.name}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧限速选择:复刻 image_24879b */}
|
{isSelected && !isAssigned && (
|
||||||
{isSelected && !isAssigned && (
|
<div>
|
||||||
<div
|
<Select
|
||||||
className="flex items-center ml-2"
|
className="w-36"
|
||||||
onClick={(e) => e.stopPropagation()}
|
classNames={{
|
||||||
>
|
trigger: "min-h-10 h-10",
|
||||||
<Select
|
}}
|
||||||
aria-label="限速选择"
|
placeholder="不限速"
|
||||||
className="w-32"
|
selectedKeys={
|
||||||
placeholder="不限速"
|
batchTunnelSelections.get(tunnel.id) !==
|
||||||
selectedKeys={
|
null &&
|
||||||
currentSpeedId
|
batchTunnelSelections.get(tunnel.id) !==
|
||||||
? [currentSpeedId.toString()]
|
undefined
|
||||||
: []
|
? [
|
||||||
}
|
batchTunnelSelections
|
||||||
size="sm"
|
.get(tunnel.id)!
|
||||||
variant="flat"
|
.toString(),
|
||||||
onSelectionChange={(keys) => {
|
]
|
||||||
const selectedKey = Array.from(keys)[0];
|
: []
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selectedKey = Array.from(keys)[0] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
updateTunnelSpeedLimit(
|
updateTunnelSpeedLimit(
|
||||||
tunnel.id,
|
tunnel.id,
|
||||||
selectedKey
|
selectedKey ? Number(selectedKey) : null,
|
||||||
? Number(selectedKey)
|
);
|
||||||
: null,
|
}}
|
||||||
);
|
>
|
||||||
}}
|
{tunnelSpeedLimits.map((sl) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sl.id.toString()}
|
||||||
|
textValue={sl.name}
|
||||||
>
|
>
|
||||||
{tunnelSpeedLimits.map((sl) => (
|
{sl.name}
|
||||||
<SelectItem key={sl.id.toString()}>
|
</SelectItem>
|
||||||
{sl.name}
|
))}
|
||||||
</SelectItem>
|
</Select>
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isAssigned && (
|
|
||||||
<span className="text-[10px] text-default-400 italic pr-2">
|
|
||||||
已分配
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex justify-end border-t border-default-200 bg-default-50/80 p-2 dark:bg-default-100/80">
|
|
||||||
<Button
|
|
||||||
className="font-bold"
|
|
||||||
color="primary"
|
|
||||||
size="md"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => setIsTunnelListExpanded(false)}
|
|
||||||
>
|
|
||||||
完成配置
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tunnels.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-default-400 bg-default-50 dark:bg-default-100/50 rounded-lg border border-dashed border-default-200 dark:border-default-100/30">
|
||||||
|
暂无可用隧道
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="w-fit px-8"
|
className="w-full sm:w-auto"
|
||||||
color="primary"
|
color="primary"
|
||||||
isDisabled={batchTunnelSelections.size === 0}
|
isDisabled={batchTunnelSelections.size === 0}
|
||||||
isLoading={assignLoading}
|
isLoading={assignLoading}
|
||||||
@@ -1732,19 +1720,20 @@ export default function UserPage() {
|
|||||||
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
|
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
|
||||||
<Table
|
<Table
|
||||||
aria-label="用户隧道权限列表"
|
aria-label="用户隧道权限列表"
|
||||||
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
|
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
|
wrapper: "shadow-none",
|
||||||
th: "bg-default-50 text-default-600 font-semibold",
|
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
|
||||||
td: "py-4 border-b border-divider",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn className="w-[35%]">隧道名称</TableColumn>
|
<TableColumn>隧道名称</TableColumn>
|
||||||
<TableColumn className="w-[35%]">流量统计</TableColumn>
|
<TableColumn>流量统计</TableColumn>
|
||||||
<TableColumn className="w-[30%] text-right">
|
<TableColumn>规则数量</TableColumn>
|
||||||
操作
|
<TableColumn>状态</TableColumn>
|
||||||
</TableColumn>
|
<TableColumn>限速规则</TableColumn>
|
||||||
|
<TableColumn>重置时间</TableColumn>
|
||||||
|
<TableColumn>到期时间</TableColumn>
|
||||||
|
<TableColumn>操作</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody
|
<TableBody
|
||||||
emptyContent="暂无隧道权限"
|
emptyContent="暂无隧道权限"
|
||||||
@@ -1753,36 +1742,61 @@ export default function UserPage() {
|
|||||||
loadingContent={<Spinner />}
|
loadingContent={<Spinner />}
|
||||||
>
|
>
|
||||||
{(userTunnel) => (
|
{(userTunnel) => (
|
||||||
<TableRow
|
<TableRow key={userTunnel.id}>
|
||||||
key={userTunnel.id}
|
<TableCell>{userTunnel.tunnelName}</TableCell>
|
||||||
className="hover:bg-default-50/50 transition-colors"
|
<TableCell>
|
||||||
>
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between text-small">
|
||||||
|
<span className="text-gray-600">限制:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatFlow(userTunnel.flow, "gb")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-small">
|
||||||
|
<span className="text-gray-600">已用:</span>
|
||||||
|
<span className="font-medium text-danger">
|
||||||
|
{formatFlow(
|
||||||
|
calculateTunnelUsedFlow(userTunnel),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{userTunnel.num}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
color={
|
||||||
|
userTunnel.status === 1 ? "success" : "danger"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{userTunnel.status === 1 ? "正常" : "禁用"}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
color={
|
||||||
|
userTunnel.speedLimitName ? "warning" : "success"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{userTunnel.speedLimitName || "不限速"}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{userTunnel.flowResetTime === 0
|
||||||
|
? "不重置"
|
||||||
|
: `每月${userTunnel.flowResetTime}号`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(userTunnel.expTime)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-default-700 whitespace-nowrap">
|
|
||||||
{userTunnel.tunnelName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1 whitespace-nowrap text-sm">
|
|
||||||
<span className="text-danger font-mono font-bold">
|
|
||||||
{formatFlow(calculateTunnelUsedFlow(userTunnel))}
|
|
||||||
</span>
|
|
||||||
<span className="text-default-300">/</span>
|
|
||||||
<span className="text-default-500 font-mono">
|
|
||||||
{formatFlow(userTunnel.flow, "gb")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
{/* 5. justify-end 确保按钮群组整体靠右 */}
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
|
aria-label="编辑隧道权限"
|
||||||
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEditTunnel(userTunnel)}
|
onPress={() => handleEditTunnel(userTunnel)}
|
||||||
@@ -1791,12 +1805,15 @@ export default function UserPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
|
aria-label="重置隧道流量"
|
||||||
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
title="重置流量"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleResetTunnelFlow(userTunnel)}
|
onPress={() => handleResetTunnelFlow(userTunnel)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@@ -1810,7 +1827,8 @@ export default function UserPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
|
aria-label="删除隧道权限"
|
||||||
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleRemoveTunnel(userTunnel)}
|
onPress={() => handleRemoveTunnel(userTunnel)}
|
||||||
@@ -1826,14 +1844,8 @@ export default function UserPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter className="justify-end">
|
<ModalFooter>
|
||||||
<Button
|
<Button onPress={onTunnelModalClose}>关闭</Button>
|
||||||
color="primary"
|
|
||||||
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
|
|
||||||
onPress={onTunnelModalClose}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user