From 157f3841f630c48427416be047186e904a65afb0 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 22:09:21 +1000 Subject: [PATCH] polish: redesign node admin UI --- src/actions/admin/nodes.ts | 8 +- src/app/(admin)/admin/commerce/page.tsx | 16 +-- .../[id]/_components/node-detail-tabs.tsx | 24 ++-- .../[id]/_components/tabs/inbounds-tab.tsx | 96 +++++++++----- .../admin/nodes/[id]/node-detail-data.ts | 3 + src/app/(admin)/admin/nodes/[id]/page.tsx | 120 ++++++++++++++---- .../nodes/_components/node-card-list.tsx | 104 ++++++++------- .../admin/nodes/inbound-delete-button.tsx | 2 + .../admin/nodes/inbound-display-name-form.tsx | 11 +- src/app/(admin)/admin/nodes/node-actions.tsx | 104 ++++++++++----- src/app/(admin)/admin/nodes/node-form.tsx | 88 ++++++++----- .../(admin)/admin/payments/config-form.tsx | 8 +- src/app/(admin)/admin/plans/plan-card.tsx | 10 +- 13 files changed, 399 insertions(+), 195 deletions(-) diff --git a/src/actions/admin/nodes.ts b/src/actions/admin/nodes.ts index bae1afc..87de394 100644 --- a/src/actions/admin/nodes.ts +++ b/src/actions/admin/nodes.ts @@ -105,6 +105,7 @@ export async function updateNode(id: string, formData: FormData) { message: `更新 3x-ui 节点 ${node.name}`, }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${id}`); return { ...result, message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`, @@ -140,6 +141,7 @@ export async function testNodeConnection(id: string) { message: `测试 3x-ui 节点 ${server.name}:${result.message}`, }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${id}`); return result; } @@ -172,7 +174,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) { const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData)); const inbound = await prisma.nodeInbound.findUniqueOrThrow({ where: { id }, - include: { server: { select: { name: true } } }, + include: { server: { select: { id: true, name: true } } }, }); await prisma.nodeInbound.update({ @@ -190,6 +192,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) { }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${inbound.server.id}`); revalidatePath("/store"); } @@ -210,6 +213,7 @@ export async function deleteInbound(id: string) { message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`, }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${inbound.server.id}`); } export async function generateAgentToken(nodeId: string) { @@ -237,6 +241,7 @@ export async function generateAgentToken(nodeId: string) { }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${nodeId}`); return plainToken; } @@ -262,4 +267,5 @@ export async function revokeAgentToken(nodeId: string) { }); revalidatePath("/admin/nodes"); + revalidatePath(`/admin/nodes/${nodeId}`); } diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx index 66489ab..40b33f1 100644 --- a/src/app/(admin)/admin/commerce/page.tsx +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -111,11 +111,11 @@ export default async function AdminCommercePage() {
{coupons.map((coupon) => (
-
- +
+
-
-

{coupon.name}

+
+

{coupon.name}

{coupon.code}

@@ -145,11 +145,11 @@ export default async function AdminCommercePage() {
{promotions.map((rule) => (
-
- +
+
-
-

{rule.name}

+
+

{rule.name}

满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}

diff --git a/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx b/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx index bcc1e57..91c6bd0 100644 --- a/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx +++ b/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx @@ -1,20 +1,22 @@ "use client"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { StatusBadge } from "@/components/shared/status-badge"; import type { NodeDetail } from "../node-detail-data"; import { InboundsTab } from "./tabs/inbounds-tab"; export function NodeDetailTabs({ node }: { node: NodeDetail }) { return ( - - - - 3x-ui 入站 ({node.inbounds.length}) - - - - - - +
+
+
+

线路入口

+

3x-ui 入站

+
+ 0 ? "info" : "neutral"}> + {node.inbounds.length} 个 + +
+ +
); } diff --git a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx index f5de78f..6799499 100644 --- a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx +++ b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx @@ -3,6 +3,7 @@ import { Waypoints } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/shared/page-shell"; +import { StatusBadge } from "@/components/shared/status-badge"; import { InboundDeleteButton } from "../../../inbound-delete-button"; import { InboundDisplayNameForm } from "../../../inbound-display-name-form"; import type { NodeDetail } from "../../node-detail-data"; @@ -16,52 +17,77 @@ function getDisplayName(inbound: { tag: string; settings: unknown }) { return inbound.tag; } +function streamValue(settings: unknown, key: string) { + if (!settings || typeof settings !== "object") return null; + const value = (settings as Record)[key]; + if (typeof value !== "string" || !value.trim()) return null; + return value; +} + +function InboundMeta({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( + + {label} + {value} + + ); +} + export function InboundsTab({ node }: { node: NodeDetail }) { if (node.inbounds.length === 0) { return ( - +
+ +
); } return ( -
-

- 入站由 3x-ui 维护,这里只调整前台名称。 -

-
- {node.inbounds.map((inbound) => ( -
-
- - +
+ {node.inbounds.map((inbound) => { + const network = streamValue(inbound.streamSettings, "network"); + const security = streamValue(inbound.streamSettings, "security"); + + return ( +
+
+ + + +
+
+ {inbound.protocol} + :{inbound.port} + {inbound.tag} +
+ +
-
- 客户端: {inbound.clients.length} - {inbound.streamSettings && typeof inbound.streamSettings === "object" && ( - <> - {(inbound.streamSettings as Record).network && ( - 传输: {String((inbound.streamSettings as Record).network)} - )} - {(inbound.streamSettings as Record).security && ( - 安全: {String((inbound.streamSettings as Record).security)} - )} - - )} + +
+ + {network && } + {security && }
-
- {inbound.protocol} - :{inbound.port} + +
-
- ))} -
+
+ ); + })}
); } diff --git a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts index 3b6eb53..9bb0f49 100644 --- a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts +++ b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts @@ -7,7 +7,9 @@ const nodeDetailSelect = { id: true, name: true, panelUrl: true, + panelUsername: true, status: true, + agentToken: true, inbounds: { where: { isActive: true }, orderBy: { updatedAt: "desc" }, @@ -32,6 +34,7 @@ export async function getNodeDetail(id: string): Promise { return { ...node, + agentToken: node.agentToken ? "configured" : null, inbounds: node.inbounds.map((inbound) => ({ ...inbound, settings: sanitizeInboundSettings(inbound.settings), diff --git a/src/app/(admin)/admin/nodes/[id]/page.tsx b/src/app/(admin)/admin/nodes/[id]/page.tsx index c4c9393..aa50b35 100644 --- a/src/app/(admin)/admin/nodes/[id]/page.tsx +++ b/src/app/(admin)/admin/nodes/[id]/page.tsx @@ -1,46 +1,122 @@ import type { Metadata } from "next"; +import type { ReactNode } from "react"; import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { PageHeader, PageShell } from "@/components/shared/page-shell"; +import { ArrowLeft, KeyRound, Server, UserRound, Waypoints } from "lucide-react"; +import { PageShell } from "@/components/shared/page-shell"; import { StatusBadge } from "@/components/shared/status-badge"; import { buttonVariants } from "@/components/ui/button"; import { getNodeStatusLabel } from "@/lib/domain-labels"; +import { getConfiguredSiteUrl } from "@/services/site-url"; import { getNodeDetail } from "./node-detail-data"; import { NodeDetailTabs } from "./_components/node-detail-tabs"; +import { NodeActions } from "../node-actions"; +import { NodeForm } from "../node-form"; export const metadata: Metadata = { title: "节点详情", }; +function DetailMetric({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: ReactNode; +}) { + return ( +
+ + {icon} + +
+

{label}

+
{value}
+
+
+ ); +} + export default async function NodeDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; - const node = await getNodeDetail(id); + const [node, siteUrl] = await Promise.all([getNodeDetail(id), getConfiguredSiteUrl()]); return ( -
- - - - - {getNodeStatusLabel(node.status)} - - } - className="flex-1" - /> -
+ + + 返回节点 + + +
+
+
+ + + +
+
+

{node.name}

+ + {getNodeStatusLabel(node.status)} + +
+

+ {node.panelUrl || "未配置面板"} +

+
+
+ +
+ + +
+
+ +
+ } + label="面板类型" + value="3x-ui" + /> + } + label="面板账号" + value={node.panelUsername || "未配置"} + /> + } + label="已同步入站" + value={`${node.inbounds.length} 个`} + /> + } + label="探测 Token" + value={node.agentToken ? "已启用" : "未生成"} + /> +
+
diff --git a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx index 76a5c7e..987584d 100644 --- a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx +++ b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx @@ -1,46 +1,89 @@ -import { Server, Waypoints } from "lucide-react"; +import { KeyRound, Server, UserRound, Waypoints } from "lucide-react"; import Link from "next/link"; 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 { getNodeStatusLabel } from "@/lib/domain-labels"; -import { InboundDeleteButton } from "../inbound-delete-button"; -import { InboundDisplayNameForm } from "../inbound-display-name-form"; import { NodeActions } from "../node-actions"; import { NodeForm } from "../node-form"; import type { NodeServerRow } from "../nodes-data"; const NODE_BATCH_FORM_ID = "node-batch-form"; -function PanelInfoBar({ node }: { node: NodeServerRow }) { +function NodeInlineMeta({ node }: { node: NodeServerRow }) { return ( -
- 3x-ui - {node.panelUrl || "未配置面板"} - {node.agentToken && 探测 Token: 已启用} +
+ {node.panelUrl || "未配置面板"} + + + {node.panelUsername || "未配置账号"} + + + + {node.agentToken ? "Token 已启用" : "未生成 Token"} + +
+ ); +} + +function InboundPreview({ node }: { node: NodeServerRow }) { + const preview = node.inbounds.slice(0, 3); + const hiddenCount = Math.max(0, node.inbounds.length - preview.length); + + if (node.inbounds.length === 0) { + return ( +
+ 暂无已同步入站 +
+ ); + } + + return ( +
+
+

可售入站

+ {node._count.inbounds} 个 +
+
+ {preview.map((inbound) => ( + + + {inbound.protocol}:{inbound.port} + {getInboundDisplayName(inbound)} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} +
); } function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) { return ( -
-
+
+
- +
-
-

+
+

{node.name} @@ -49,38 +92,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu {getNodeStatusLabel(node.status)}

-

- {node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站 -

+

-
- -
- - {node.inbounds.length > 0 ? ( -
- {node.inbounds.map((inbound) => ( -
- - {inbound.protocol} · {inbound.port} - - -
- ))} -
- ) : ( -

- 暂无已同步入站。 -

- )} +
-
+
); } diff --git a/src/app/(admin)/admin/nodes/inbound-delete-button.tsx b/src/app/(admin)/admin/nodes/inbound-delete-button.tsx index b592640..149eac3 100644 --- a/src/app/(admin)/admin/nodes/inbound-delete-button.tsx +++ b/src/app/(admin)/admin/nodes/inbound-delete-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trash2 } from "lucide-react"; import { deleteInbound } from "@/actions/admin/nodes"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; @@ -20,6 +21,7 @@ export function InboundDeleteButton({ errorMessage="删除线路入口失败" onConfirm={() => deleteInbound(inboundId)} > + 删除 ); diff --git a/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx b/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx index 9ad00c1..b2059fc 100644 --- a/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx +++ b/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { Save } from "lucide-react"; import { updateInboundDisplayName } from "@/actions/admin/nodes"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -14,6 +15,7 @@ export function InboundDisplayNameForm({ inboundId: string; defaultValue: string; }) { + const inputId = useId(); const [saving, setSaving] = useState(false); async function handleSubmit(formData: FormData) { @@ -30,13 +32,18 @@ export function InboundDisplayNameForm({ return (
+
diff --git a/src/app/(admin)/admin/nodes/node-actions.tsx b/src/app/(admin)/admin/nodes/node-actions.tsx index 6f312c2..4806965 100644 --- a/src/app/(admin)/admin/nodes/node-actions.tsx +++ b/src/app/(admin)/admin/nodes/node-actions.tsx @@ -1,15 +1,10 @@ "use client"; import { useState } from "react"; -import { KeyRound, Terminal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { KeyRound, RefreshCw, ShieldOff, Terminal, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -19,6 +14,7 @@ import { } from "@/components/ui/dialog"; import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes"; import { getErrorMessage } from "@/lib/errors"; +import { cn } from "@/lib/utils"; import { toast } from "sonner"; interface NodeActionValue { @@ -45,72 +41,114 @@ function buildInstallCommand(token: string, siteUrl: string | null) { return `curl -fsSL ${INSTALL_SCRIPT_URL} | SERVER_URL=${shellQuote(serverUrl)} AUTH_TOKEN=${shellQuote(token)} bash`; } -export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl: string | null }) { +export function NodeActions({ + node, + siteUrl, + deleteRedirectHref, +}: { + node: NodeActionValue; + siteUrl: string | null; + deleteRedirectHref?: string; +}) { + const router = useRouter(); const [tokenDialogOpen, setTokenDialogOpen] = useState(false); const [plainToken, setPlainToken] = useState(""); const [installCommand, setInstallCommand] = useState(""); - const hasToken = !!node.agentToken; + const [hasToken, setHasToken] = useState(!!node.agentToken); + const [syncing, setSyncing] = useState(false); + const [tokenLoading, setTokenLoading] = useState(false); + + async function handleSync() { + setSyncing(true); + try { + const res = await testNodeConnection(node.id); + if (res.success) toast.success(res.message); + else toast.error(getErrorMessage(res.message, "节点测试失败")); + router.refresh(); + } catch (error) { + toast.error(getErrorMessage(error, "测试失败")); + } finally { + setSyncing(false); + } + } async function handleGenerateToken() { + setTokenLoading(true); try { const token = await generateAgentToken(node.id); + setHasToken(true); setPlainToken(token); setInstallCommand(buildInstallCommand(token, siteUrl)); setTokenDialogOpen(true); + router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "生成 Token 失败")); + } finally { + setTokenLoading(false); } } return ( - <> - - }>... - - { - try { - const res = await testNodeConnection(node.id); - if (res.success) toast.success(res.message); - else toast.error(getErrorMessage(res.message, "节点测试失败")); - } catch (error) { - toast.error(getErrorMessage(error, "测试失败")); - } - }} - > - 测试并同步入站 - - - {hasToken ? "重新生成探测 Token" : "生成探测 Token"} - - - +
+ + + {hasToken && ( revokeAgentToken(node.id)} + onSuccess={() => { + setHasToken(false); + router.refresh(); + }} > + 撤销 Token )} deleteNode(node.id)} + onSuccess={() => { + if (deleteRedirectHref) router.push(deleteRedirectHref); + else router.refresh(); + }} > + 删除 @@ -175,6 +213,6 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
- +
); } diff --git a/src/app/(admin)/admin/nodes/node-form.tsx b/src/app/(admin)/admin/nodes/node-form.tsx index 6bbdd86..2f73f29 100644 --- a/src/app/(admin)/admin/nodes/node-form.tsx +++ b/src/app/(admin)/admin/nodes/node-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { Plus, Server, Pencil } from "lucide-react"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,6 +10,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -35,6 +37,10 @@ export function NodeForm({ }) { const isEdit = Boolean(node); const [open, setOpen] = useState(false); + const nameId = isEdit ? `node-name-${node!.id}` : "node-name"; + const panelUrlId = isEdit ? `node-panel-url-${node!.id}` : "node-panel-url"; + const panelUsernameId = isEdit ? `node-panel-username-${node!.id}` : "node-panel-username"; + const panelPasswordId = isEdit ? `node-panel-password-${node!.id}` : "node-panel-password"; async function handleCreate(formData: FormData) { try { @@ -63,46 +69,68 @@ export function NodeForm({ } > + {isEdit ? : } {triggerLabel || (isEdit ? "编辑" : "添加节点")} - + - {isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"} - 保存后登录 3x-ui 并同步入站。 +
+ +
+ {isEdit ? "编辑节点" : "添加节点"} + 连接 3x-ui 面板并同步可售入站。
-
-
-
- - -
-
- - + + +
+
基础信息
+
+
+ + +
+
+ + +
-
-
- - -
-
- - +
+
面板凭据
+
+
+ + +
+
+ + +
-

探测使用 Token,客户端操作走 3x-ui API。

- - {isEdit ? "保存并同步入站" : "创建并同步入站"} - + + + + {isEdit ? "保存并同步" : "创建并同步"} + + diff --git a/src/app/(admin)/admin/payments/config-form.tsx b/src/app/(admin)/admin/payments/config-form.tsx index d35ed7b..a935bbe 100644 --- a/src/app/(admin)/admin/payments/config-form.tsx +++ b/src/app/(admin)/admin/payments/config-form.tsx @@ -160,13 +160,13 @@ export function PaymentConfigItem({ return (
-
- +
+
-
-

{providerName}

+
+

{providerName}

{displayName && {displayName}}

{providerDescription}

diff --git a/src/app/(admin)/admin/plans/plan-card.tsx b/src/app/(admin)/admin/plans/plan-card.tsx index da41736..7c887c4 100644 --- a/src/app/(admin)/admin/plans/plan-card.tsx +++ b/src/app/(admin)/admin/plans/plan-card.tsx @@ -119,21 +119,21 @@ export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardP return (
-
+
- +
-
-

{plan.name}

+
+

{plan.name}

{plan.type === "PROXY" ? "代理" : "流媒体"}