Polish admin list UI for lite

This commit is contained in:
JetSprow
2026-04-30 20:49:03 +10:00
parent d666f1450f
commit 6ee9cf2857
11 changed files with 244 additions and 258 deletions

View File

@@ -1,7 +1,6 @@
"use client";
import { Waypoints } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/page-shell";
import { InboundDeleteButton } from "../../../inbound-delete-button";
@@ -32,41 +31,35 @@ export function InboundsTab({ node }: { node: NodeDetail }) {
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
3x-ui 线
</p>
<div className="grid gap-3">
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{node.inbounds.map((inbound) => (
<Card key={inbound.id}>
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
<div className="flex min-w-0 items-center gap-2.5">
<Waypoints className="size-4 shrink-0 text-primary" />
<CardTitle className="text-sm">
<InboundDisplayNameForm
inboundId={inbound.id}
defaultValue={getDisplayName(inbound)}
/>
</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{inbound.protocol}</Badge>
<Badge variant="outline">:{inbound.port}</Badge>
<InboundDeleteButton inboundId={inbound.id} />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>: {inbound.clients.length}</span>
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
<>
{(inbound.streamSettings as Record<string, unknown>).network && (
<span>: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
)}
{(inbound.streamSettings as Record<string, unknown>).security && (
<span>: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
)}
</>
)}
</div>
</CardContent>
</Card>
<section key={inbound.id} className="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-2.5">
<Waypoints className="size-4 shrink-0 text-primary" />
<InboundDisplayNameForm
inboundId={inbound.id}
defaultValue={getDisplayName(inbound)}
/>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: {inbound.clients.length}</span>
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
<>
{(inbound.streamSettings as Record<string, unknown>).network && (
<span>: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
)}
{(inbound.streamSettings as Record<string, unknown>).security && (
<span>: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
)}
</>
)}
</div>
<div className="flex items-center gap-2 lg:justify-end">
<Badge variant="secondary">{inbound.protocol}</Badge>
<Badge variant="outline">:{inbound.port}</Badge>
<InboundDeleteButton inboundId={inbound.id} />
</div>
</section>
))}
</div>
</div>

View File

@@ -4,7 +4,6 @@ import { batchTestNodeConnections } from "@/actions/admin/nodes";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getNodeStatusLabel } from "@/lib/domain-labels";
import { InboundDeleteButton } from "../inbound-delete-button";
import { InboundDisplayNameForm } from "../inbound-display-name-form";
@@ -16,7 +15,7 @@ const NODE_BATCH_FORM_ID = "node-batch-form";
function PanelInfoBar({ node }: { node: NodeServerRow }) {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-muted/25 px-4 py-3 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">3x-ui</span>
<span>{node.panelUrl || "未配置面板"}</span>
{node.agentToken && <span> Token: 已启用</span>}
@@ -26,75 +25,78 @@ function PanelInfoBar({ node }: { node: NodeServerRow }) {
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
return (
<Card>
<CardHeader className="flex flex-col gap-4 pb-2 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-start gap-3">
<input
form={NODE_BATCH_FORM_ID}
type="checkbox"
name="nodeIds"
value={node.id}
aria-label={`选择节点 ${node.name}`}
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
/>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Server className="size-5" />
</span>
<div className="min-w-0">
<CardTitle className="text-lg">
<section className="grid gap-4 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_minmax(14rem,0.55fr)_minmax(20rem,0.95fr)_auto] xl:items-start">
<div className="flex min-w-0 items-start gap-3">
<input
form={NODE_BATCH_FORM_ID}
type="checkbox"
name="nodeIds"
value={node.id}
aria-label={`选择节点 ${node.name}`}
className="mt-2 size-5 rounded-lg border-border accent-primary shadow-sm"
/>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Server className="size-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold tracking-tight">
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
{node.name}
</Link>
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{node.panelUrl || "未配置面板"} · {node._count.inbounds}
</p>
</h3>
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{getNodeStatusLabel(node.status)}
</StatusBadge>
</div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">
{node.panelUrl || "未配置面板"} · {node._count.inbounds}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{getNodeStatusLabel(node.status)}
</StatusBadge>
<NodeForm
node={{
id: node.id,
name: node.name,
panelUrl: node.panelUrl,
panelUsername: node.panelUsername,
}}
triggerLabel="编辑"
triggerVariant="outline"
/>
<NodeActions
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
siteUrl={siteUrl}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
</div>
<div className="min-w-0">
<PanelInfoBar node={node} />
{node.inbounds.length > 0 ? (
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
{node.inbounds.map((inbound) => (
<div
key={inbound.id}
className="flex min-w-0 flex-wrap items-center gap-2 border-b border-border/50 pb-2 text-xs font-medium last:border-b-0 last:pb-0"
>
<Waypoints className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
<InboundDisplayNameForm
inboundId={inbound.id}
defaultValue={getInboundDisplayName(inbound)}
/>
<InboundDeleteButton inboundId={inbound.id} />
</div>
))}
</div>
) : (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground"> 3x-ui </p>
)}
</CardContent>
</Card>
</div>
{node.inbounds.length > 0 ? (
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
{node.inbounds.map((inbound) => (
<div
key={inbound.id}
className="flex min-w-0 flex-wrap items-center gap-2 border-b border-border/50 pb-2 text-xs font-medium last:border-b-0 last:pb-0"
>
<Waypoints className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
<InboundDisplayNameForm
inboundId={inbound.id}
defaultValue={getInboundDisplayName(inbound)}
/>
<InboundDeleteButton inboundId={inbound.id} />
</div>
))}
</div>
) : (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground"> 3x-ui </p>
)}
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<NodeForm
node={{
id: node.id,
name: node.name,
panelUrl: node.panelUrl,
panelUsername: node.panelUsername,
}}
triggerLabel="编辑"
triggerVariant="outline"
/>
<NodeActions
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
siteUrl={siteUrl}
/>
</div>
</section>
);
}
@@ -104,16 +106,18 @@ export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteU
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
<BatchActionButton></BatchActionButton>
</BatchActionBar>
<div className="grid gap-5">
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{nodes.map((node) => (
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
))}
{nodes.length === 0 && (
<EmptyState
title="暂无节点"
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
action={<NodeForm triggerLabel="添加节点" />}
/>
<div className="p-5">
<EmptyState
title="暂无节点"
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
action={<NodeForm triggerLabel="添加节点" />}
/>
</div>
)}
</div>
</>

View File

@@ -27,7 +27,7 @@ interface NodeActionValue {
agentToken: string | null;
}
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/lite/scripts/install-jboard-agent.sh";
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-agent.sh";
function shellQuote(value: string) {
return `'${value.replaceAll("'", `'"'"'`)}'`;