feat(ui): update list view tables styling for Node, Monitor, User, Tunnel, and Limit (#354)

This commit is contained in:
sagit
2026-03-20 13:15:15 +08:00
committed by GitHub
parent fe42a77409
commit 75cd60ea3e
6 changed files with 276 additions and 12 deletions
+82 -3
View File
@@ -18,6 +18,15 @@ import {
ModalFooter, ModalFooter,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import { import {
createSpeedLimit, createSpeedLimit,
getSpeedLimitList, getSpeedLimitList,
@@ -51,6 +60,10 @@ export default function LimitPage() {
"", "",
); );
const [isSearchVisible, setIsSearchVisible] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"limit-view-mode",
"grid",
);
const filteredRules = useMemo(() => { const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules; if (!searchKeyword.trim()) return rules;
@@ -231,13 +244,78 @@ export default function LimitPage() {
/> />
</div> </div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}> <div className="flex items-center gap-2">
新增 <Button
</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 color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div> </div>
{/* 统一卡片网格 */} {/* 统一卡片网格 */}
{filteredRules.length > 0 ? ( {filteredRules.length > 0 ? (
viewMode === "list" ? (
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn align="center">操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell className="font-medium text-foreground">{rule.name}</TableCell>
<TableCell>{rule.speed} Mbps</TableCell>
<TableCell>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 justify-center">
<Button
color="primary"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> <StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => ( {filteredRules.map((rule) => (
<StaggerItem key={rule.id}> <StaggerItem key={rule.id}>
@@ -324,6 +402,7 @@ export default function LimitPage() {
</StaggerItem> </StaggerItem>
))} ))}
</StaggerList> </StaggerList>
)
) : ( ) : (
/* 空状态 */ /* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50"> <Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+9 -1
View File
@@ -1768,7 +1768,15 @@ export default function NodePage() {
/> />
) : viewMode === "list" ? ( ) : viewMode === "list" ? (
<Card> <Card>
<Table aria-label="节点列表" className="overflow-x-auto min-w-full"> <Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader> <TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden"> <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox <Checkbox
+19 -3
View File
@@ -1065,7 +1065,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
</div> </div>
) : ( ) : (
<Card className="w-full"> <Card className="w-full">
<Table aria-label="节点列表"> <Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader> <TableHeader>
<TableColumn>状态</TableColumn> <TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn> <TableColumn>名称</TableColumn>
@@ -1084,7 +1092,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const isOnline = node.connectionStatus === "online"; const isOnline = node.connectionStatus === "online";
return ( return (
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0"> <TableRow key={node.id}>
<TableCell> <TableCell>
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} /> <div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell> </TableCell>
@@ -1536,7 +1544,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<RefreshCw className="w-6 h-6 animate-spin" /> <RefreshCw className="w-6 h-6 animate-spin" />
</div> </div>
) : modalResults.length > 0 ? ( ) : modalResults.length > 0 ? (
<Table aria-label="监控记录" className="w-full"> <Table
aria-label="监控记录"
className="w-full overflow-x-auto"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader> <TableHeader>
<TableColumn>时间</TableColumn> <TableColumn>时间</TableColumn>
<TableColumn>结果</TableColumn> <TableColumn>结果</TableColumn>
@@ -681,7 +681,15 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div> </div>
) : ( ) : (
<Card className="w-full"> <Card className="w-full">
<Table aria-label="隧道列表"> <Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader> <TableHeader>
<TableColumn>状态</TableColumn> <TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn> <TableColumn>名称</TableColumn>
@@ -699,7 +707,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
: null; : null;
return ( return (
<TableRow key={tunnel.id} className="border-b border-divider/50 last:border-b-0 cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}> <TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableCell> <TableCell>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{isEnabled ? ( {isEnabled ? (
+144
View File
@@ -45,6 +45,15 @@ import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio"; import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import { import {
createTunnel, createTunnel,
batchDeleteTunnelsWithForwards, batchDeleteTunnelsWithForwards,
@@ -181,6 +190,10 @@ export default function TunnelPage() {
"", "",
); );
const [isSearchVisible, setIsSearchVisible] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"tunnel-view-mode",
"grid",
);
// 模态框状态 // 模态框状态
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
> >
批量 批量
</Button> </Button>
<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 <Button
color="primary" color="primary"
size="sm" size="sm"
@@ -1556,6 +1577,128 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */} {/* 隧道卡片网格 */}
{tunnels.length > 0 ? ( {tunnels.length > 0 ? (
viewMode === "list" ? (
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode ? <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectedIds.size === sortedTunnels.length && sortedTunnels.length > 0}
onValueChange={(checked) => checked ? selectAll() : deselectAll()}
/>
</TableColumn> : <TableColumn className="w-0 p-0 overflow-hidden text-[0px]"></TableColumn>}
<TableColumn>隧道名称</TableColumn>
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn align="center">操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<TableRow key={tunnel.id}>
{selectMode ? <TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(tunnel.id)}
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell className="font-medium text-foreground">{tunnel.name}</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
>
{typeDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0}跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0}出口
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-xs">
<span className="text-default-500">{getTunnelFlowDisplay(tunnel.flow)}</span>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.trafficRatio}x</span>
{tunnel.type === 2 && tunnel.ipPreference && (
<>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}</span>
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
color="primary"
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd" />
</svg>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" fillRule="evenodd" />
<path clipRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" fillRule="evenodd" />
</svg>
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext <SortableContext
items={sortableTunnelIds} items={sortableTunnelIds}
@@ -1836,6 +1979,7 @@ export default function TunnelPage() {
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
)
) : ( ) : (
/* 空状态 */ /* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50"> <Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+12 -3
View File
@@ -1080,7 +1080,15 @@ export default function UserPage() {
</Card> </Card>
) : viewMode === "list" ? ( ) : viewMode === "list" ? (
<Card> <Card>
<Table aria-label="用户列表" className="overflow-x-auto min-w-full"> <Table
aria-label="用户列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader> <TableHeader>
<TableColumn>用户名</TableColumn> <TableColumn>用户名</TableColumn>
<TableColumn>流量统计</TableColumn> <TableColumn>流量统计</TableColumn>
@@ -1888,8 +1896,9 @@ export default function UserPage() {
<Table <Table
aria-label="用户隧道权限列表" aria-label="用户隧道权限列表"
classNames={{ classNames={{
wrapper: "shadow-none", th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}} }}
> >
<TableHeader> <TableHeader>