mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +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 (
|
||||
<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}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm leading-5">{node.name}</h3>
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${isOnline ? "bg-emerald-500" : "bg-rose-500"}`}
|
||||
title={isOnline ? "在线" : "离线"}
|
||||
/>
|
||||
</div>
|
||||
{/* Dynamic top gradient bar based on status */}
|
||||
<div className={`absolute top-0 left-0 right-0 h-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||
|
||||
{/* Decorative background glow */}
|
||||
<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"}`} />
|
||||
|
||||
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<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">
|
||||
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
|
||||
</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 className="flex items-center gap-2 text-xs text-default-500 flex-shrink-0">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="font-mono text-[11px]">{metric ? formatUptime(metric.uptime) : "-"}</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h3 className="font-semibold text-foreground text-sm truncate">{node.name}</h3>
|
||||
<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>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3 flex-1 flex flex-col">
|
||||
{/* IP & Version row */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs font-mono">{metric ? "在线" : "-"}</span>
|
||||
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-4 z-10 w-full overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-5 w-full">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-default-500">CPU</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>
|
||||
|
||||
{/* System metrics */}
|
||||
<div className="space-y-3 mb-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<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 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="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-default-500">
|
||||
<span className="text-primary-500 inline-block font-bold">↑</span>
|
||||
<span>上传</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-semibold truncate">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<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.netOutSpeed) : "-"}</div>
|
||||
</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 className="w-px h-6 bg-divider mx-2" />
|
||||
<div className="flex flex-col gap-0.5 items-end min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-default-500">
|
||||
<span>下载</span>
|
||||
<span className="text-success-500 inline-block font-bold">↓</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-semibold truncate">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">↑ 上行流量</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<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 className="flex justify-between items-center mt-1 pt-3 border-t border-divider/50 w-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-default-500 min-w-0">
|
||||
<Clock className="w-3.5 h-3.5 text-default-400 shrink-0" />
|
||||
<span className="font-mono truncate">{metric ? formatUptime(metric.uptime) : "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-default-500 shrink-0 ml-2">
|
||||
<Activity className="w-3.5 h-3.5 text-default-400" />
|
||||
<span className="font-mono">Load: {metric ? metric.load1.toFixed(2) : "-"}</span>
|
||||
</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>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -1282,19 +1226,21 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
|
||||
|
||||
{/* Realtime KPI cards */}
|
||||
{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: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) },
|
||||
{ label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) },
|
||||
{ label: "↓ 下行", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const },
|
||||
{ label: "↑ 上行", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const },
|
||||
{ label: "↓ 下行速度", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const },
|
||||
{ label: "↑ 上行速度", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const },
|
||||
{ label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const },
|
||||
].map((item) => (
|
||||
<Card key={item.label}>
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center">
|
||||
<span className="text-[11px] text-default-500 mb-1">{item.label}</span>
|
||||
<span className="text-sm font-semibold font-mono">{item.value}</span>
|
||||
<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 justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">{item.label}</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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
+186
-174
@@ -245,7 +245,6 @@ export default function UserPage() {
|
||||
|
||||
// 分配新隧道权限相关状态
|
||||
const [assignLoading, setAssignLoading] = useState(false);
|
||||
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
|
||||
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
|
||||
Map<number, number | null>
|
||||
>(new Map());
|
||||
@@ -1519,7 +1518,7 @@ export default function UserPage() {
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
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}
|
||||
isOpen={isTunnelModalOpen}
|
||||
@@ -1570,147 +1569,136 @@ export default function UserPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<p className="text-base text-default-700 ml-1 font-medium">
|
||||
隧道列表
|
||||
</p>
|
||||
<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">
|
||||
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
|
||||
</div>
|
||||
|
||||
{/* 顶部触发框 */}
|
||||
<div
|
||||
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"}`}
|
||||
onClick={() =>
|
||||
setIsTunnelListExpanded(!isTunnelListExpanded)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
|
||||
{tunnels.map((tunnel) => {
|
||||
const isAssigned = isTunnelAssigned(tunnel.id);
|
||||
const isSelected = batchTunnelSelections.has(tunnel.id);
|
||||
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
|
||||
tunnel.id,
|
||||
);
|
||||
|
||||
{/* 列表悬浮层 */}
|
||||
{isTunnelListExpanded && (
|
||||
<div
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
|
||||
{tunnels.map((tunnel) => {
|
||||
const isAssigned = isTunnelAssigned(tunnel.id);
|
||||
const isSelected = batchTunnelSelections.has(
|
||||
tunnel.id,
|
||||
);
|
||||
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
|
||||
tunnel.id,
|
||||
);
|
||||
const currentSpeedId = batchTunnelSelections.get(
|
||||
tunnel.id,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={tunnel.id}
|
||||
aria-disabled={isAssigned}
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
|
||||
${
|
||||
isAssigned
|
||||
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
||||
: isSelected
|
||||
? "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"
|
||||
}
|
||||
`}
|
||||
role="button"
|
||||
tabIndex={isAssigned ? -1 : 0}
|
||||
onClick={() =>
|
||||
!isAssigned && toggleTunnelSelection(tunnel.id)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (isAssigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={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;
|
||||
e.stopPropagation();
|
||||
toggleTunnelSelection(tunnel.id);
|
||||
}}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggleTunnelSelection(tunnel.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<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">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
isSelected={isSelected}
|
||||
isDisabled={isAssigned}
|
||||
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span
|
||||
className={`truncate text-sm font-medium text-foreground ${isSelected ? "text-primary-700 dark:text-primary-300" : ""}`}
|
||||
>
|
||||
{tunnel.name}
|
||||
</span>
|
||||
</div>
|
||||
{tunnel.name}
|
||||
</span>
|
||||
{isAssigned && (
|
||||
<Chip
|
||||
className="shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已分配
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧限速选择:复刻 image_24879b */}
|
||||
{isSelected && !isAssigned && (
|
||||
<div
|
||||
className="flex items-center ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Select
|
||||
aria-label="限速选择"
|
||||
className="w-32"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
currentSpeedId
|
||||
? [currentSpeedId.toString()]
|
||||
: []
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0];
|
||||
{isSelected && !isAssigned && (
|
||||
<div>
|
||||
<Select
|
||||
className="w-36"
|
||||
classNames={{
|
||||
trigger: "min-h-10 h-10",
|
||||
}}
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
null &&
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
undefined
|
||||
? [
|
||||
batchTunnelSelections
|
||||
.get(tunnel.id)!
|
||||
.toString(),
|
||||
]
|
||||
: []
|
||||
}
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
updateTunnelSpeedLimit(
|
||||
tunnel.id,
|
||||
selectedKey
|
||||
? Number(selectedKey)
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
updateTunnelSpeedLimit(
|
||||
tunnel.id,
|
||||
selectedKey ? Number(selectedKey) : null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tunnelSpeedLimits.map((sl) => (
|
||||
<SelectItem
|
||||
key={sl.id.toString()}
|
||||
textValue={sl.name}
|
||||
>
|
||||
{tunnelSpeedLimits.map((sl) => (
|
||||
<SelectItem key={sl.id.toString()}>
|
||||
{sl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isAssigned && (
|
||||
<span className="text-[10px] text-default-400 italic pr-2">
|
||||
已分配
|
||||
</span>
|
||||
)}
|
||||
{sl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</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 className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="w-fit px-8"
|
||||
className="w-full sm:w-auto"
|
||||
color="primary"
|
||||
isDisabled={batchTunnelSelections.size === 0}
|
||||
isLoading={assignLoading}
|
||||
@@ -1732,19 +1720,20 @@ export default function UserPage() {
|
||||
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
|
||||
<Table
|
||||
aria-label="用户隧道权限列表"
|
||||
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
|
||||
classNames={{
|
||||
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
|
||||
th: "bg-default-50 text-default-600 font-semibold",
|
||||
td: "py-4 border-b border-divider",
|
||||
wrapper: "shadow-none",
|
||||
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn className="w-[35%]">隧道名称</TableColumn>
|
||||
<TableColumn className="w-[35%]">流量统计</TableColumn>
|
||||
<TableColumn className="w-[30%] text-right">
|
||||
操作
|
||||
</TableColumn>
|
||||
<TableColumn>隧道名称</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
<TableColumn>规则数量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>限速规则</TableColumn>
|
||||
<TableColumn>重置时间</TableColumn>
|
||||
<TableColumn>到期时间</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无隧道权限"
|
||||
@@ -1753,36 +1742,61 @@ export default function UserPage() {
|
||||
loadingContent={<Spinner />}
|
||||
>
|
||||
{(userTunnel) => (
|
||||
<TableRow
|
||||
key={userTunnel.id}
|
||||
className="hover:bg-default-50/50 transition-colors"
|
||||
>
|
||||
<TableRow key={userTunnel.id}>
|
||||
<TableCell>{userTunnel.tunnelName}</TableCell>
|
||||
<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>
|
||||
<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
|
||||
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"
|
||||
variant="flat"
|
||||
onPress={() => handleEditTunnel(userTunnel)}
|
||||
@@ -1791,12 +1805,15 @@ export default function UserPage() {
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
title="重置流量"
|
||||
variant="flat"
|
||||
onPress={() => handleResetTunnelFlow(userTunnel)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1810,7 +1827,8 @@ export default function UserPage() {
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
|
||||
aria-label="删除隧道权限"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleRemoveTunnel(userTunnel)}
|
||||
@@ -1826,14 +1844,8 @@ export default function UserPage() {
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className="justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
|
||||
onPress={onTunnelModalClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<ModalFooter>
|
||||
<Button onPress={onTunnelModalClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user