mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db21ce6bb4 | |||
| 76ad841231 | |||
| d0535707dc | |||
| 6458b5af00 | |||
| 555039e028 | |||
| 7134253b2c | |||
| 58d29b440a | |||
| 6a3a9add08 |
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Wed Mar 18 2026
|
||||
**Commit:** ea21a7d
|
||||
**Generated:** Thu Mar 19 2026
|
||||
**Commit:** 6458b5a
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.8
|
||||
**Tag:** 2.1.9-alpha5
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
# Monitor Nezha Redesign
|
||||
|
||||
## Summary
|
||||
Redesign the monitor overview to resemble Nezha dashboard by introducing a Server Matrix (Grid) containing cards with metrics visualisations.
|
||||
Redesign the monitor overview to resemble Nezha dashboard. Phase 1 introduced ServerCard grid. Phase 2 hides node metrics from the overview and adds a detail view with charts and service monitor latency sparklines.
|
||||
|
||||
## Objectives
|
||||
- [x] Analyze `monitor-view.tsx` layout boundaries.
|
||||
- [x] Analyze `monitor-view.tsx` layout boundaries.
|
||||
- [x] Create `ServerCard` utilizing `Progress` component.
|
||||
- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeeds).
|
||||
- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeds).
|
||||
- [x] Implement robust real-time updates and seamless state linkage via existing hooks.
|
||||
- [x] Adjust layout positioning for impact at top-of-page.
|
||||
- [x] Hide node metrics from overview — click node card to enter detail view.
|
||||
- [x] Detail view: back button + node header + realtime KPI cards.
|
||||
- [x] Detail view: node metrics chart (CPU/Memory/Disk/Network/Load/Connections).
|
||||
- [x] Detail view: tunnel traffic chart.
|
||||
- [x] Detail view: service monitors rendered as latency sparkline charts (Nezha-style).
|
||||
- [x] Service monitor cards include status dot, type chip, target, interval, and dropdown actions.
|
||||
|
||||
## Technical Details
|
||||
Injected a `ServerCard` inline component mapping over unified node metric states. Used the tailwind and hero UI components matching visual designs like progress bars and small monospace typograhy. Extracted utility color scales.
|
||||
- Phase 1: Injected a `ServerCard` inline component with progress bars and real-time metrics.
|
||||
- Phase 2: Added `detailNodeId` state for drill-down navigation. Grid view shows only summary bar + clickable server cards. Detail view shows KPI summary cards, node metrics chart (reusing existing recharts setup), tunnel traffic chart, and service monitors as a responsive grid of cards each containing a latency-over-time sparkline chart. Reduced file from 2108 to 1799 lines by consolidating the old overview card into the summary bar.
|
||||
|
||||
## Status: Complete
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 046 - Node Card Cleanup & Monitor Redesign
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Remove duplicate system metrics from node cards in `node.tsx` (CPU, memory, upload/download speed, upload/download traffic, disk, load)
|
||||
- [x] 2. Redesign monitor `ServerCard` in `monitor-view.tsx` to match the node card style from `node.tsx`
|
||||
- [x] 3. Make all charts in monitor view update incrementally (streaming) instead of full page reload
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,11 @@
|
||||
# 048 - Release 2.1.9-alpha5
|
||||
|
||||
## Goal
|
||||
Release the current state of `main` (including the monitor tab beautification and user page improvements) under the tag `2.1.9-alpha5`.
|
||||
|
||||
## Tasks
|
||||
- [x] Update `AGENTS.md` with current date, branch, and anticipated tag.
|
||||
- [x] Create a release branch `release/2.1.9-alpha5`.
|
||||
- [x] Commit and push the branch.
|
||||
- [x] Create a Pull Request and merge it into `main`.
|
||||
- [x] Publish the new tag `2.1.9-alpha5`.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 049 - Monitor List View
|
||||
|
||||
## Objective
|
||||
The user requested that the Monitor page should switch its card-based grid view to a list view similar to a provided screenshot, and a view mode toggle should be added to the top right.
|
||||
|
||||
## Expected Features
|
||||
1. View Mode Toggle
|
||||
- Add a state `viewMode` in `pages/monitor.tsx`.
|
||||
- Add a toggle button with LayoutGrid/List icons next to the refresh button.
|
||||
- Pass `viewMode` down to `MonitorView` component.
|
||||
2. List View Implementation
|
||||
- Extend `MonitorViewProps` with `viewMode: "list" | "grid"`.
|
||||
- Render the `ServerCard` grid when `viewMode === "grid"`.
|
||||
- Render a `Table` when `viewMode === "list"`.
|
||||
- The list view should include:
|
||||
- 状态 (Status: Colored dot depending on `isOnline`).
|
||||
- 名称 (Name: Node name).
|
||||
- 速率 (Speed: Up/Down speeds styled appropriately).
|
||||
- 流量 (Traffic: Total Up/Down bytes).
|
||||
- 开机时长 (Uptime).
|
||||
- 连接数 (Connections: TCP/UDP).
|
||||
- CPU (Progress bar).
|
||||
- RAM (Progress bar).
|
||||
- 存储 (Storage / Disk Progress bar).
|
||||
- 操作 (Actions: Eye view icon to open detailed monitor).
|
||||
|
||||
## Checklist
|
||||
- [x] Create plan document.
|
||||
- [x] Add viewMode state and toggle in `monitor.tsx`.
|
||||
- [x] Receive viewMode in `MonitorView` and selectively render Grid vs List views.
|
||||
- [x] Ensure list correctly visualizes node info, speed, traffic, uptime, conns, CPU/RAM/Disk usages, and action icons.
|
||||
- [x] Fix HeroUI missing TableProps typings (remove `removeWrapper` prop).
|
||||
@@ -2,7 +2,7 @@ import type { MonitorNodeApiItem } from "@/api/types";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, LayoutGrid, List } from "lucide-react";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
@@ -20,9 +20,11 @@ export default function MonitorPage() {
|
||||
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
||||
const [nodesLoading, setNodesLoading] = useState(false);
|
||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
setNodesLoading(true);
|
||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setNodesLoading(true);
|
||||
try {
|
||||
const response = await getMonitorNodes();
|
||||
|
||||
@@ -40,11 +42,11 @@ export default function MonitorPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(response.msg || "加载节点失败");
|
||||
if (!silent) toast.error(response.msg || "加载节点失败");
|
||||
} catch {
|
||||
toast.error("加载节点失败");
|
||||
if (!silent) toast.error("加载节点失败");
|
||||
} finally {
|
||||
setNodesLoading(false);
|
||||
if (!silent) setNodesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -54,7 +56,7 @@ export default function MonitorPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadNodes();
|
||||
void loadNodes({ silent: true });
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
@@ -82,15 +84,25 @@ export default function MonitorPage() {
|
||||
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={nodesLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={loadNodes}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新节点
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
||||
>
|
||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={nodesLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => loadNodes()}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nodesError ? (
|
||||
@@ -105,7 +117,7 @@ export default function MonitorPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MonitorView nodeMap={nodeMap} />
|
||||
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -683,15 +683,7 @@ export default function NodePage() {
|
||||
};
|
||||
|
||||
// 格式化流量
|
||||
const formatTraffic = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatFlow = (bytes: number): string => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
@@ -719,17 +711,6 @@ export default function NodePage() {
|
||||
return "未知链路";
|
||||
};
|
||||
|
||||
// 获取进度条颜色
|
||||
const getProgressColor = (
|
||||
value: number,
|
||||
offline = false,
|
||||
): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => {
|
||||
if (offline) return "default";
|
||||
if (value <= 50) return "success";
|
||||
if (value <= 80) return "warning";
|
||||
|
||||
return "danger";
|
||||
};
|
||||
|
||||
// IPv4/IPv6 格式验证(仅用于判定地址族)
|
||||
const ipv4Regex =
|
||||
@@ -2162,235 +2143,7 @@ export default function NodePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<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">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.uploadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</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">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.downloadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<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">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.uploadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</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">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 扩展指标:磁盘/负载/连接 */}
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo && (
|
||||
<div className="space-y-2">
|
||||
{node.systemInfo.diskUsage !==
|
||||
undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>磁盘</span>
|
||||
<span className="font-mono">
|
||||
{node.systemInfo.diskUsage.toFixed(
|
||||
1,
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="磁盘使用率"
|
||||
color={getProgressColor(
|
||||
node.systemInfo.diskUsage,
|
||||
false,
|
||||
)}
|
||||
size="sm"
|
||||
value={node.systemInfo.diskUsage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(node.systemInfo.load1 !== undefined ||
|
||||
node.systemInfo.load5 !== undefined ||
|
||||
node.systemInfo.load15 !==
|
||||
undefined) && (
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
{node.systemInfo.load1 !==
|
||||
undefined && (
|
||||
<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">
|
||||
{node.systemInfo.load1.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.load5 !==
|
||||
undefined && (
|
||||
<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">
|
||||
{node.systemInfo.load5.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.load15 !==
|
||||
undefined && (
|
||||
<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">
|
||||
{node.systemInfo.load15.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(node.systemInfo.tcpConns !==
|
||||
undefined ||
|
||||
node.systemInfo.udpConns !==
|
||||
undefined) && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{node.systemInfo.tcpConns !==
|
||||
undefined && (
|
||||
<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">
|
||||
{node.systemInfo.tcpConns}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.udpConns !==
|
||||
undefined && (
|
||||
<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">
|
||||
{node.systemInfo.udpConns}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
{/* 操作按钮 */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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