mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: align node and user list view UI
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# 053 List View Styling
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Update the design of Node and User list table views to match the provided screenshot:
|
||||||
|
- Use colored dots for status instead of separate "Status" text columns.
|
||||||
|
- The action buttons should have text labels (e.g. "安装", "编辑") with `variant="flat"` instead of icons.
|
||||||
|
- Add version column for Node list.
|
||||||
|
- Remove traffic columns from Node list as requested.
|
||||||
|
- Adjust User list to match this clean style.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Update `pages/node.tsx` list view to use the new column layout (Node name with dot, Address, Version, Actions).
|
||||||
|
- [x] Update action buttons in `pages/node.tsx` list view to use text instead of icons.
|
||||||
|
- [x] Update `pages/user.tsx` list view to use the status dot pattern.
|
||||||
|
- [x] Update action buttons in `pages/user.tsx` list view to use text instead of icons.
|
||||||
|
- [x] Ensure `selectionMode="multiple"` (or similar) is properly reflected.
|
||||||
@@ -20,7 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
import { SearchBar } from "@/components/search-bar";
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
import { LayoutGrid, List, Terminal, ArrowUpCircle, RotateCcw, Edit, Trash2 } from "lucide-react";
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -1770,140 +1770,99 @@ export default function NodePage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
|
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||||
|
<Checkbox
|
||||||
|
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
|
||||||
|
onValueChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
selectAll();
|
||||||
|
setSelectMode(true);
|
||||||
|
} else {
|
||||||
|
deselectAll();
|
||||||
|
setSelectMode(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableColumn>
|
||||||
<TableColumn>节点名称</TableColumn>
|
<TableColumn>节点名称</TableColumn>
|
||||||
<TableColumn>状态</TableColumn>
|
<TableColumn>地址</TableColumn>
|
||||||
<TableColumn>协议/端口</TableColumn>
|
<TableColumn>版本</TableColumn>
|
||||||
<TableColumn>系统信息</TableColumn>
|
|
||||||
<TableColumn>到期时间</TableColumn>
|
|
||||||
<TableColumn>操作</TableColumn>
|
<TableColumn>操作</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody items={displayNodes}>
|
<TableBody items={displayNodes}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const isRemoteNode = node.isRemote === 1;
|
const isRemoteNode = node.isRemote === 1;
|
||||||
const remoteUsage = isRemoteNode
|
|
||||||
? remoteUsageMap[node.id]
|
|
||||||
: null;
|
|
||||||
const expiryMeta = getNodeExpiryMeta(
|
|
||||||
node.expiryTime,
|
|
||||||
node.renewalCycle,
|
|
||||||
);
|
|
||||||
const connectionStatusMeta = getConnectionStatusMeta(
|
|
||||||
node.connectionStatus,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasRemark = Boolean(node.remark?.trim());
|
const hasRemark = Boolean(node.remark?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={node.id}>
|
<TableRow key={node.id}>
|
||||||
<TableCell>
|
<TableCell className="px-4">
|
||||||
<div className="flex flex-col">
|
<Checkbox
|
||||||
<div className="flex items-center gap-2">
|
isSelected={selectedIds.has(node.id)}
|
||||||
<span className="font-medium text-foreground text-sm">
|
onValueChange={(checked) => {
|
||||||
{node.name}
|
if (checked) {
|
||||||
</span>
|
setSelectMode(true);
|
||||||
{hasRemark && (
|
setSelectedIds((prev) => new Set([...prev, node.id]));
|
||||||
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
|
} else {
|
||||||
{node.remark}
|
setSelectedIds((prev) => {
|
||||||
</Chip>
|
const next = new Set(prev);
|
||||||
)}
|
next.delete(node.id);
|
||||||
</div>
|
if (next.size === 0) setSelectMode(false);
|
||||||
<span className="text-xs text-default-500 font-mono mt-0.5">
|
return next;
|
||||||
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
|
});
|
||||||
</span>
|
}
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<div className="flex items-center gap-2">
|
||||||
variant="flat"
|
<div
|
||||||
size="sm"
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||||
color={connectionStatusMeta.color}
|
node.connectionStatus === "online"
|
||||||
className="text-xs"
|
? "bg-success"
|
||||||
>
|
: node.syncError
|
||||||
<div className="flex items-center gap-1.5">
|
? "bg-danger"
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${node.connectionStatus === "online" ? "bg-success" : node.syncError ? "bg-danger" : "bg-default-400"} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`} />
|
: "bg-default-400"
|
||||||
<span>{connectionStatusMeta.text}</span>
|
} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`}
|
||||||
</div>
|
/>
|
||||||
</Chip>
|
<span className="font-medium text-foreground text-sm">
|
||||||
</TableCell>
|
{node.name}
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{isRemoteNode ? "Remote" : "Local"}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-default-500 font-mono">
|
{hasRemark && (
|
||||||
{isRemoteNode
|
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
|
||||||
? (remoteUsage ? `${remoteUsage.portRangeStart}-${remoteUsage.portRangeEnd}` : "未知端口")
|
{node.remark}
|
||||||
: node.port}
|
</Chip>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px] min-w-[100px]">
|
|
||||||
{!isRemoteNode && node.systemInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-default-500">CPU</span>
|
|
||||||
<span className={node.systemInfo.cpuUsage > 80 ? "text-danger font-medium" : ""}>
|
|
||||||
{node.systemInfo.cpuUsage.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-default-500">RAM</span>
|
|
||||||
<span className={node.systemInfo.memoryUsage > 80 ? "text-danger font-medium" : ""}>
|
|
||||||
{node.systemInfo.memoryUsage.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-400">-</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-1 items-start whitespace-nowrap min-w-[100px]">
|
<span className="text-sm font-mono text-default-600">
|
||||||
{node.expiryTime ? (
|
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
|
||||||
<>
|
</span>
|
||||||
<span className="text-sm">
|
|
||||||
{new Date(node.expiryTime).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{expiryMeta && expiryMeta.state !== "healthy" && expiryMeta.state !== "permanent" && (
|
|
||||||
<Chip
|
|
||||||
color={expiryMeta.tone}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
className="h-5 px-1 text-[10px]"
|
|
||||||
>
|
|
||||||
{expiryMeta.label}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-400 text-sm">无限期</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<span className="text-sm text-default-500">
|
||||||
|
{node.version || "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="success"
|
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||||
className="min-w-8 min-h-8 px-1"
|
|
||||||
title="安装命令"
|
|
||||||
isLoading={node.copyLoading}
|
isLoading={node.copyLoading}
|
||||||
onPress={() => openInstallSelector(node)}
|
onPress={() => openInstallSelector(node)}
|
||||||
>
|
>
|
||||||
<Terminal className="w-4 h-4" />
|
安装
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="warning"
|
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
||||||
className="min-w-8 min-h-8 px-1"
|
|
||||||
title="升级"
|
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
isLoading={node.upgradeLoading}
|
isLoading={node.upgradeLoading}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -1911,44 +1870,38 @@ export default function NodePage() {
|
|||||||
openUpgradeModal("single");
|
openUpgradeModal("single");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArrowUpCircle className="w-4 h-4" />
|
升级
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="secondary"
|
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
|
||||||
className="min-w-8 min-h-8 px-1"
|
|
||||||
title="回退"
|
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
isLoading={node.rollbackLoading}
|
isLoading={node.rollbackLoading}
|
||||||
onPress={() => handleRollbackNode(node)}
|
onPress={() => handleRollbackNode(node)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
回退
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="primary"
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
className="min-w-8 min-h-8 px-1"
|
|
||||||
title="编辑"
|
|
||||||
onPress={() => handleEdit(node)}
|
onPress={() => handleEdit(node)}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="danger"
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
className="min-w-8 min-h-8 px-1"
|
|
||||||
title="删除"
|
|
||||||
onPress={() => handleDelete(node)}
|
onPress={() => handleDelete(node)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1083,7 +1083,6 @@ export default function UserPage() {
|
|||||||
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
|
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn>用户名</TableColumn>
|
<TableColumn>用户名</TableColumn>
|
||||||
<TableColumn>状态</TableColumn>
|
|
||||||
<TableColumn>流量统计</TableColumn>
|
<TableColumn>流量统计</TableColumn>
|
||||||
<TableColumn>配额限制</TableColumn>
|
<TableColumn>配额限制</TableColumn>
|
||||||
<TableColumn>规则数量</TableColumn>
|
<TableColumn>规则数量</TableColumn>
|
||||||
@@ -1101,35 +1100,27 @@ export default function UserPage() {
|
|||||||
return (
|
return (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-foreground text-sm">
|
<div
|
||||||
{user.name || user.user}
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||||
</span>
|
userStatus.color === "success"
|
||||||
<span className="text-xs text-default-500">
|
? "bg-success"
|
||||||
@{user.user}
|
: "bg-danger"
|
||||||
</span>
|
}`}
|
||||||
</div>
|
/>
|
||||||
</TableCell>
|
<div className="flex flex-col">
|
||||||
<TableCell>
|
<span className="font-medium text-foreground text-sm">
|
||||||
<div className="flex flex-col gap-1 items-start">
|
{user.name || user.user}
|
||||||
<Chip
|
</span>
|
||||||
className="text-xs"
|
<span className="text-xs text-default-500">
|
||||||
color={userStatus.color}
|
@{user.user}
|
||||||
size="sm"
|
</span>
|
||||||
variant="flat"
|
</div>
|
||||||
>
|
{user.disabledByQuota && (
|
||||||
{userStatus.text}
|
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
|
||||||
</Chip>
|
超额
|
||||||
{user.disabledByQuota ? (
|
|
||||||
<Chip
|
|
||||||
className="text-[10px] h-5 px-1"
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
配额超额
|
|
||||||
</Chip>
|
</Chip>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -1193,55 +1184,39 @@ export default function UserPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="success"
|
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||||
className="min-h-8 min-w-[50px] px-2"
|
|
||||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
|
||||||
onPress={() => handleManageTunnels(user)}
|
onPress={() => handleManageTunnels(user)}
|
||||||
>
|
>
|
||||||
权限
|
权限
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="flat"
|
||||||
className="text-default-600 hover:text-primary min-h-8 min-w-8"
|
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
||||||
onPress={() => handleEdit(user)}
|
|
||||||
>
|
|
||||||
<EditIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
className="text-default-600 hover:text-warning min-h-8 min-w-8"
|
|
||||||
title="重置流量"
|
title="重置流量"
|
||||||
onPress={() => handleResetFlow(user)}
|
onPress={() => handleResetFlow(user)}
|
||||||
>
|
>
|
||||||
<svg
|
重置
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
|
||||||
fillRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="flat"
|
||||||
className="text-default-600 hover:text-danger min-h-8 min-w-8"
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
|
onPress={() => handleEdit(user)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
onPress={() => handleDelete(user)}
|
onPress={() => handleDelete(user)}
|
||||||
>
|
>
|
||||||
<DeleteIcon className="w-4 h-4" />
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
Reference in New Issue
Block a user