polish: redesign node admin UI

This commit is contained in:
JetSprow
2026-04-30 22:09:21 +10:00
parent c5592621a4
commit 157f3841f6
13 changed files with 399 additions and 195 deletions

View File

@@ -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}`);
}

View File

@@ -111,11 +111,11 @@ export default async function AdminCommercePage() {
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{coupons.map((coupon) => (
<article key={coupon.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.9fr)_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{coupon.name}</h3>
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-semibold leading-6">{coupon.name}</h3>
<ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div>
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p>
@@ -145,11 +145,11 @@ export default async function AdminCommercePage() {
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{promotions.map((rule) => (
<article key={rule.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{rule.name}</h3>
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-semibold leading-6">{rule.name}</h3>
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div>
<p className="mt-1 text-sm text-muted-foreground"> ¥{Number(rule.thresholdAmount).toFixed(2)} ¥{Number(rule.discountAmount).toFixed(2)}</p>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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),

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -160,13 +160,13 @@ export function PaymentConfigItem({
return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3>
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>

View File

@@ -119,21 +119,21 @@ export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardP
return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<div className="flex min-w-0 items-center gap-3">
<input
form={batchFormId}
type="checkbox"
name="planIds"
value={plan.id}
aria-label={`选择套餐 ${plan.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">
<Icon 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">{plan.name}</h3>
<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">{plan.name}</h3>
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
{plan.type === "PROXY" ? "代理" : "流媒体"}
</StatusBadge>