mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
polish: redesign node admin UI
This commit is contained in:
@@ -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 (
|
||||
<Tabs defaultValue="inbounds">
|
||||
<TabsList variant="line" className="w-full overflow-x-auto">
|
||||
<TabsTrigger value="inbounds">
|
||||
3x-ui 入站 ({node.inbounds.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbounds">
|
||||
<InboundsTab node={node} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">线路入口</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">3x-ui 入站</h2>
|
||||
</div>
|
||||
<StatusBadge tone={node.inbounds.length > 0 ? "info" : "neutral"}>
|
||||
{node.inbounds.length} 个
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<InboundsTab node={node} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)[key];
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function InboundMeta({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-border bg-muted/25 px-2.5 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold text-foreground">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboundsTab({ node }: { node: NodeDetail }) {
|
||||
if (node.inbounds.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无已同步入站"
|
||||
description="在 3x-ui 创建后回到节点列表同步。"
|
||||
/>
|
||||
<div className="surface-card rounded-xl p-5">
|
||||
<EmptyState
|
||||
title="暂无已同步入站"
|
||||
description="同步节点后会显示可售入口。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-4">
|
||||
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
入站由 3x-ui 维护,这里只调整前台名称。
|
||||
</p>
|
||||
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<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 className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||
{node.inbounds.map((inbound) => {
|
||||
const network = streamValue(inbound.streamSettings, "network");
|
||||
const security = streamValue(inbound.streamSettings, "security");
|
||||
|
||||
return (
|
||||
<article key={inbound.id} className="grid gap-4 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] xl:items-center">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Waypoints className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||
<StatusBadge tone="info">{inbound.protocol}</StatusBadge>
|
||||
<Badge variant="outline">:{inbound.port}</Badge>
|
||||
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">{inbound.tag}</span>
|
||||
</div>
|
||||
<InboundDisplayNameForm
|
||||
inboundId={inbound.id}
|
||||
defaultValue={getDisplayName(inbound)}
|
||||
/>
|
||||
</div>
|
||||
</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 className="flex flex-wrap gap-2">
|
||||
<InboundMeta label="客户端" value={inbound.clients.length} />
|
||||
{network && <InboundMeta label="传输" value={network} />}
|
||||
{security && <InboundMeta label="安全" value={security} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 lg:justify-end">
|
||||
<Badge variant="secondary">{inbound.protocol}</Badge>
|
||||
<Badge variant="outline">:{inbound.port}</Badge>
|
||||
|
||||
<div className="flex justify-start xl:justify-end">
|
||||
<InboundDeleteButton inboundId={inbound.id} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NodeDetail> {
|
||||
|
||||
return {
|
||||
...node,
|
||||
agentToken: node.agentToken ? "configured" : null,
|
||||
inbounds: node.inbounds.map((inbound) => ({
|
||||
...inbound,
|
||||
settings: sanitizeInboundSettings(inbound.settings),
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-w-0 items-center gap-3 bg-muted/20 px-4 py-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background/70 text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<div className="mt-0.5 truncate text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageShell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/admin/nodes"
|
||||
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow="基础设施"
|
||||
title={node.name}
|
||||
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
|
||||
actions={
|
||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||
{getNodeStatusLabel(node.status)}
|
||||
</StatusBadge>
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/nodes"
|
||||
className={buttonVariants({ variant: "ghost", size: "sm", className: "w-fit" })}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回节点
|
||||
</Link>
|
||||
|
||||
<section className="surface-card overflow-hidden rounded-xl">
|
||||
<div className="flex flex-col gap-5 p-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Server className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-h-7 flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-2xl font-semibold leading-8 tracking-tight">{node.name}</h1>
|
||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||
{getNodeStatusLabel(node.status)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 break-all text-sm text-muted-foreground">
|
||||
{node.panelUrl || "未配置面板"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
deleteRedirectHref="/admin/nodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px border-t border-border/60 bg-border/60 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailMetric
|
||||
icon={<Server className="size-4" />}
|
||||
label="面板类型"
|
||||
value="3x-ui"
|
||||
/>
|
||||
<DetailMetric
|
||||
icon={<UserRound className="size-4" />}
|
||||
label="面板账号"
|
||||
value={node.panelUsername || "未配置"}
|
||||
/>
|
||||
<DetailMetric
|
||||
icon={<Waypoints className="size-4" />}
|
||||
label="已同步入站"
|
||||
value={`${node.inbounds.length} 个`}
|
||||
/>
|
||||
<DetailMetric
|
||||
icon={<KeyRound className="size-4" />}
|
||||
label="探测 Token"
|
||||
value={node.agentToken ? "已启用" : "未生成"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NodeDetailTabs node={node} />
|
||||
</PageShell>
|
||||
|
||||
@@ -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 (
|
||||
<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>}
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 max-w-full truncate">{node.panelUrl || "未配置面板"}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<UserRound className="size-3" />
|
||||
{node.panelUsername || "未配置账号"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<KeyRound className="size-3" />
|
||||
{node.agentToken ? "Token 已启用" : "未生成 Token"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-12 items-center rounded-lg border border-dashed border-border bg-muted/20 px-3 text-xs text-muted-foreground">
|
||||
暂无已同步入站
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-6 items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">可售入站</p>
|
||||
<StatusBadge tone="neutral">{node._count.inbounds} 个</StatusBadge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{preview.map((inbound) => (
|
||||
<span
|
||||
key={inbound.id}
|
||||
className="inline-flex min-h-8 max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-2.5 text-xs font-medium"
|
||||
>
|
||||
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="shrink-0 text-muted-foreground">{inbound.protocol}:{inbound.port}</span>
|
||||
<span className="min-w-0 truncate">{getInboundDisplayName(inbound)}</span>
|
||||
</span>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<span className="inline-flex min-h-8 items-center rounded-lg border border-border bg-muted/25 px-2.5 text-xs font-medium text-muted-foreground">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
||||
return (
|
||||
<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">
|
||||
<article className="grid gap-4 px-4 py-4 transition-colors duration-200 hover:bg-muted/15 xl:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)_auto] xl:items-center">
|
||||
<div className="flex min-w-0 items-center 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"
|
||||
className="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">
|
||||
<span className="flex size-10 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">
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">
|
||||
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
||||
{node.name}
|
||||
</Link>
|
||||
@@ -49,38 +92,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
|
||||
{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>
|
||||
<NodeInlineMeta node={node} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<PanelInfoBar node={node} />
|
||||
</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">
|
||||
暂无已同步入站。
|
||||
</p>
|
||||
)}
|
||||
<InboundPreview node={node} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<NodeForm
|
||||
@@ -98,7 +114,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
|
||||
siteUrl={siteUrl}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
前台名称
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
name="displayName"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="例如 悉尼 · 日常优选"
|
||||
className="h-8 min-h-8 rounded-xl px-3 text-xs"
|
||||
className="h-9 min-h-9 rounded-lg px-3 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="xs" variant="outline" disabled={saving}>
|
||||
<Save className="size-3" />
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
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, "测试失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试并同步入站
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleGenerateToken}>
|
||||
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", syncing && "animate-spin")} />
|
||||
{syncing ? "同步中" : "同步"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleGenerateToken()}
|
||||
disabled={tokenLoading}
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
{hasToken ? "重置 Token" : "Token"}
|
||||
</Button>
|
||||
|
||||
{hasToken && (
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-500/25 text-amber-700 hover:bg-amber-500/10 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200"
|
||||
title="撤销这个探测 Token?"
|
||||
description="撤销后探测 Agent 停止上报。"
|
||||
confirmLabel="撤销 Token"
|
||||
successMessage="探测 Token 已撤销"
|
||||
errorMessage="撤销失败"
|
||||
onConfirm={() => revokeAgentToken(node.id)}
|
||||
onSuccess={() => {
|
||||
setHasToken(false);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<ShieldOff className="size-3.5" />
|
||||
撤销 Token
|
||||
</ConfirmActionButton>
|
||||
)}
|
||||
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
className="border-destructive/25 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
title="删除这个节点?"
|
||||
description="会清理节点、入口和探测数据。"
|
||||
confirmLabel="删除节点"
|
||||
successMessage="节点已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteNode(node.id)}
|
||||
onSuccess={() => {
|
||||
if (deleteRedirectHref) router.push(deleteRedirectHref);
|
||||
else router.refresh();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
|
||||
@@ -175,6 +213,6 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<DialogTrigger
|
||||
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||
>
|
||||
{isEdit ? <Pencil className="size-3.5" /> : <Plus className="size-4" />}
|
||||
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle>
|
||||
<DialogDescription>保存后登录 3x-ui 并同步入站。</DialogDescription>
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<DialogTitle>{isEdit ? "编辑节点" : "添加节点"}</DialogTitle>
|
||||
<DialogDescription>连接 3x-ui 面板并同步可售入站。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>节点名称</Label>
|
||||
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>3x-ui 面板地址</Label>
|
||||
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
|
||||
|
||||
<form action={isEdit ? handleEdit : handleCreate} className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-semibold">基础信息</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={nameId}>节点名称</Label>
|
||||
<Input id={nameId} name="name" defaultValue={node?.name ?? ""} placeholder="HK-01" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={panelUrlId}>3x-ui 面板地址</Label>
|
||||
<Input
|
||||
id={panelUrlId}
|
||||
name="panelUrl"
|
||||
defaultValue={node?.panelUrl ?? ""}
|
||||
placeholder="http://1.2.3.4:2053"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>面板用户名</Label>
|
||||
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>面板密码</Label>
|
||||
<Input
|
||||
name="panelPassword"
|
||||
type="password"
|
||||
placeholder={isEdit ? "留空不变" : "面板密码"}
|
||||
required={!isEdit}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-semibold">面板凭据</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={panelUsernameId}>面板用户名</Label>
|
||||
<Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={panelPasswordId}>面板密码</Label>
|
||||
<Input
|
||||
id={panelPasswordId}
|
||||
name="panelPassword"
|
||||
type="password"
|
||||
placeholder={isEdit ? "留空不变" : "面板密码"}
|
||||
required={!isEdit}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">探测使用 Token,客户端操作走 3x-ui API。</p>
|
||||
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存并同步入站" : "创建并同步入站"}
|
||||
</PendingSubmitButton>
|
||||
<DialogFooter className="-mx-6 -mb-6">
|
||||
<Button type="button" variant="outline" size="lg" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<PendingSubmitButton size="lg" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存并同步" : "创建并同步"}
|
||||
</PendingSubmitButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user