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}`, message: `更新 3x-ui 节点 ${node.name}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${id}`);
return { return {
...result, ...result,
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`, message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
@@ -140,6 +141,7 @@ export async function testNodeConnection(id: string) {
message: `测试 3x-ui 节点 ${server.name}${result.message}`, message: `测试 3x-ui 节点 ${server.name}${result.message}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${id}`);
return result; return result;
} }
@@ -172,7 +174,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData)); const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
const inbound = await prisma.nodeInbound.findUniqueOrThrow({ const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id }, where: { id },
include: { server: { select: { name: true } } }, include: { server: { select: { id: true, name: true } } },
}); });
await prisma.nodeInbound.update({ await prisma.nodeInbound.update({
@@ -190,6 +192,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${inbound.server.id}`);
revalidatePath("/store"); revalidatePath("/store");
} }
@@ -210,6 +213,7 @@ export async function deleteInbound(id: string) {
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`, message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${inbound.server.id}`);
} }
export async function generateAgentToken(nodeId: string) { export async function generateAgentToken(nodeId: string) {
@@ -237,6 +241,7 @@ export async function generateAgentToken(nodeId: string) {
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${nodeId}`);
return plainToken; return plainToken;
} }
@@ -262,4 +267,5 @@ export async function revokeAgentToken(nodeId: string) {
}); });
revalidatePath("/admin/nodes"); 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"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{coupons.map((coupon) => ( {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"> <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"> <div className="flex min-w-0 items-center 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> <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="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{coupon.name}</h3> <h3 className="min-w-0 truncate font-semibold leading-6">{coupon.name}</h3>
<ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" /> <ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div> </div>
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p> <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"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{promotions.map((rule) => ( {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"> <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"> <div className="flex min-w-0 items-center 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> <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="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{rule.name}</h3> <h3 className="min-w-0 truncate font-semibold leading-6">{rule.name}</h3>
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" /> <ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> ¥{Number(rule.thresholdAmount).toFixed(2)} ¥{Number(rule.discountAmount).toFixed(2)}</p> <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"; "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 type { NodeDetail } from "../node-detail-data";
import { InboundsTab } from "./tabs/inbounds-tab"; import { InboundsTab } from "./tabs/inbounds-tab";
export function NodeDetailTabs({ node }: { node: NodeDetail }) { export function NodeDetailTabs({ node }: { node: NodeDetail }) {
return ( return (
<Tabs defaultValue="inbounds"> <section className="space-y-4">
<TabsList variant="line" className="w-full overflow-x-auto"> <div className="flex flex-wrap items-center justify-between gap-3">
<TabsTrigger value="inbounds"> <div>
3x-ui ({node.inbounds.length}) <p className="text-xs font-medium tracking-wide text-muted-foreground">线</p>
</TabsTrigger> <h2 className="text-lg font-semibold tracking-tight">3x-ui </h2>
</TabsList> </div>
<TabsContent value="inbounds"> <StatusBadge tone={node.inbounds.length > 0 ? "info" : "neutral"}>
<InboundsTab node={node} /> {node.inbounds.length}
</TabsContent> </StatusBadge>
</Tabs> </div>
<InboundsTab node={node} />
</section>
); );
} }

View File

@@ -3,6 +3,7 @@
import { Waypoints } from "lucide-react"; import { Waypoints } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { InboundDeleteButton } from "../../../inbound-delete-button"; import { InboundDeleteButton } from "../../../inbound-delete-button";
import { InboundDisplayNameForm } from "../../../inbound-display-name-form"; import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
import type { NodeDetail } from "../../node-detail-data"; import type { NodeDetail } from "../../node-detail-data";
@@ -16,52 +17,77 @@ function getDisplayName(inbound: { tag: string; settings: unknown }) {
return inbound.tag; 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 }) { export function InboundsTab({ node }: { node: NodeDetail }) {
if (node.inbounds.length === 0) { if (node.inbounds.length === 0) {
return ( return (
<EmptyState <div className="surface-card rounded-xl p-5">
title="暂无已同步入站" <EmptyState
description="在 3x-ui 创建后回到节点列表同步。" title="暂无已同步入站"
/> description="同步节点后会显示可售入口。"
/>
</div>
); );
} }
return ( return (
<div className="space-y-4 pt-4"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground"> {node.inbounds.map((inbound) => {
3x-ui const network = streamValue(inbound.streamSettings, "network");
</p> const security = streamValue(inbound.streamSettings, "security");
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{node.inbounds.map((inbound) => ( return (
<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"> <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-2.5"> <div className="flex min-w-0 items-center gap-3">
<Waypoints className="size-4 shrink-0 text-primary" /> <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<InboundDisplayNameForm <Waypoints className="size-4" />
inboundId={inbound.id} </span>
defaultValue={getDisplayName(inbound)} <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>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: {inbound.clients.length}</span> <div className="flex flex-wrap gap-2">
{inbound.streamSettings && typeof inbound.streamSettings === "object" && ( <InboundMeta label="客户端" value={inbound.clients.length} />
<> {network && <InboundMeta label="传输" value={network} />}
{(inbound.streamSettings as Record<string, unknown>).network && ( {security && <InboundMeta label="安全" value={security} />}
<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>
<div className="flex items-center gap-2 lg:justify-end">
<Badge variant="secondary">{inbound.protocol}</Badge> <div className="flex justify-start xl:justify-end">
<Badge variant="outline">:{inbound.port}</Badge>
<InboundDeleteButton inboundId={inbound.id} /> <InboundDeleteButton inboundId={inbound.id} />
</div> </div>
</section> </article>
))} );
</div> })}
</div> </div>
); );
} }

View File

@@ -7,7 +7,9 @@ const nodeDetailSelect = {
id: true, id: true,
name: true, name: true,
panelUrl: true, panelUrl: true,
panelUsername: true,
status: true, status: true,
agentToken: true,
inbounds: { inbounds: {
where: { isActive: true }, where: { isActive: true },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
@@ -32,6 +34,7 @@ export async function getNodeDetail(id: string): Promise<NodeDetail> {
return { return {
...node, ...node,
agentToken: node.agentToken ? "configured" : null,
inbounds: node.inbounds.map((inbound) => ({ inbounds: node.inbounds.map((inbound) => ({
...inbound, ...inbound,
settings: sanitizeInboundSettings(inbound.settings), settings: sanitizeInboundSettings(inbound.settings),

View File

@@ -1,46 +1,122 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, KeyRound, Server, UserRound, Waypoints } from "lucide-react";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageShell } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { getNodeStatusLabel } from "@/lib/domain-labels"; import { getNodeStatusLabel } from "@/lib/domain-labels";
import { getConfiguredSiteUrl } from "@/services/site-url";
import { getNodeDetail } from "./node-detail-data"; import { getNodeDetail } from "./node-detail-data";
import { NodeDetailTabs } from "./_components/node-detail-tabs"; import { NodeDetailTabs } from "./_components/node-detail-tabs";
import { NodeActions } from "../node-actions";
import { NodeForm } from "../node-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "节点详情", 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({ export default async function NodeDetailPage({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const node = await getNodeDetail(id); const [node, siteUrl] = await Promise.all([getNodeDetail(id), getConfiguredSiteUrl()]);
return ( return (
<PageShell> <PageShell>
<div className="flex items-center gap-2"> <Link
<Link href="/admin/nodes"
href="/admin/nodes" className={buttonVariants({ variant: "ghost", size: "sm", className: "w-fit" })}
className={buttonVariants({ variant: "ghost", size: "icon" })} >
> <ArrowLeft className="size-4" />
<ArrowLeft className="size-4" />
</Link> </Link>
<PageHeader
eyebrow="基础设施" <section className="surface-card overflow-hidden rounded-xl">
title={node.name} <div className="flex flex-col gap-5 p-5 xl:flex-row xl:items-start xl:justify-between">
description={`3x-ui · ${node.panelUrl || "未配置面板"}`} <div className="flex min-w-0 items-start gap-4">
actions={ <span className="flex size-12 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}> <Server className="size-5" />
{getNodeStatusLabel(node.status)} </span>
</StatusBadge> <div className="min-w-0">
} <div className="flex min-h-7 flex-wrap items-center gap-2">
className="flex-1" <h1 className="min-w-0 truncate text-2xl font-semibold leading-8 tracking-tight">{node.name}</h1>
/> <StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
</div> {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} /> <NodeDetailTabs node={node} />
</PageShell> </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 Link from "next/link";
import { batchTestNodeConnections } from "@/actions/admin/nodes"; import { batchTestNodeConnections } from "@/actions/admin/nodes";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar"; import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { getNodeStatusLabel } from "@/lib/domain-labels"; 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 { NodeActions } from "../node-actions";
import { NodeForm } from "../node-form"; import { NodeForm } from "../node-form";
import type { NodeServerRow } from "../nodes-data"; import type { NodeServerRow } from "../nodes-data";
const NODE_BATCH_FORM_ID = "node-batch-form"; const NODE_BATCH_FORM_ID = "node-batch-form";
function PanelInfoBar({ node }: { node: NodeServerRow }) { function NodeInlineMeta({ node }: { node: NodeServerRow }) {
return ( return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"> <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="font-medium text-foreground">3x-ui</span> <span className="min-w-0 max-w-full truncate">{node.panelUrl || "未配置面板"}</span>
<span>{node.panelUrl || "未配置面板"}</span> <span className="inline-flex items-center gap-1">
{node.agentToken && <span> Token: 已启用</span>} <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> </div>
); );
} }
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) { function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
return ( 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"> <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-start gap-3"> <div className="flex min-w-0 items-center gap-3">
<input <input
form={NODE_BATCH_FORM_ID} form={NODE_BATCH_FORM_ID}
type="checkbox" type="checkbox"
name="nodeIds" name="nodeIds"
value={node.id} value={node.id}
aria-label={`选择节点 ${node.name}`} 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" /> <Server className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold tracking-tight"> <h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">
<Link href={`/admin/nodes/${node.id}`} className="hover:underline"> <Link href={`/admin/nodes/${node.id}`} className="hover:underline">
{node.name} {node.name}
</Link> </Link>
@@ -49,38 +92,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
{getNodeStatusLabel(node.status)} {getNodeStatusLabel(node.status)}
</StatusBadge> </StatusBadge>
</div> </div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground"> <NodeInlineMeta node={node} />
{node.panelUrl || "未配置面板"} · {node._count.inbounds}
</p>
</div> </div>
</div> </div>
<div className="min-w-0"> <InboundPreview node={node} />
<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>
)}
<div className="flex flex-wrap items-center gap-2 xl:justify-end"> <div className="flex flex-wrap items-center gap-2 xl:justify-end">
<NodeForm <NodeForm
@@ -98,7 +114,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
siteUrl={siteUrl} siteUrl={siteUrl}
/> />
</div> </div>
</section> </article>
); );
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Trash2 } from "lucide-react";
import { deleteInbound } from "@/actions/admin/nodes"; import { deleteInbound } from "@/actions/admin/nodes";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
@@ -20,6 +21,7 @@ export function InboundDeleteButton({
errorMessage="删除线路入口失败" errorMessage="删除线路入口失败"
onConfirm={() => deleteInbound(inboundId)} onConfirm={() => deleteInbound(inboundId)}
> >
<Trash2 className="size-3" />
</ConfirmActionButton> </ConfirmActionButton>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useId, useState } from "react";
import { Save } from "lucide-react";
import { updateInboundDisplayName } from "@/actions/admin/nodes"; import { updateInboundDisplayName } from "@/actions/admin/nodes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -14,6 +15,7 @@ export function InboundDisplayNameForm({
inboundId: string; inboundId: string;
defaultValue: string; defaultValue: string;
}) { }) {
const inputId = useId();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function handleSubmit(formData: FormData) { async function handleSubmit(formData: FormData) {
@@ -30,13 +32,18 @@ export function InboundDisplayNameForm({
return ( return (
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2"> <form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
<label htmlFor={inputId} className="sr-only">
</label>
<Input <Input
id={inputId}
name="displayName" name="displayName"
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder="例如 悉尼 · 日常优选" 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}> <Button type="submit" size="xs" variant="outline" disabled={saving}>
<Save className="size-3" />
{saving ? "保存中" : "保存"} {saving ? "保存中" : "保存"}
</Button> </Button>
</form> </form>

View File

@@ -1,15 +1,10 @@
"use client"; "use client";
import { useState } from "react"; 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 { Button } from "@/components/ui/button";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,6 +14,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes"; import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
interface NodeActionValue { 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`; 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 [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [plainToken, setPlainToken] = useState(""); const [plainToken, setPlainToken] = useState("");
const [installCommand, setInstallCommand] = 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() { async function handleGenerateToken() {
setTokenLoading(true);
try { try {
const token = await generateAgentToken(node.id); const token = await generateAgentToken(node.id);
setHasToken(true);
setPlainToken(token); setPlainToken(token);
setInstallCommand(buildInstallCommand(token, siteUrl)); setInstallCommand(buildInstallCommand(token, siteUrl));
setTokenDialogOpen(true); setTokenDialogOpen(true);
router.refresh();
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, "生成 Token 失败")); toast.error(getErrorMessage(error, "生成 Token 失败"));
} finally {
setTokenLoading(false);
} }
} }
return ( return (
<> <div className="flex flex-wrap items-center gap-2">
<DropdownMenu> <Button
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger> type="button"
<DropdownMenuContent> variant="outline"
<DropdownMenuItem size="sm"
onClick={async () => { onClick={() => void handleSync()}
try { disabled={syncing}
const res = await testNodeConnection(node.id); >
if (res.success) toast.success(res.message); <RefreshCw className={cn("size-3.5", syncing && "animate-spin")} />
else toast.error(getErrorMessage(res.message, "节点测试失败")); {syncing ? "同步中" : "同步"}
} catch (error) { </Button>
toast.error(getErrorMessage(error, "测试失败"));
} <Button
}} type="button"
> variant="outline"
size="sm"
</DropdownMenuItem> onClick={() => void handleGenerateToken()}
<DropdownMenuItem onClick={handleGenerateToken}> disabled={tokenLoading}
{hasToken ? "重新生成探测 Token" : "生成探测 Token"} >
</DropdownMenuItem> <KeyRound className="size-3.5" />
</DropdownMenuContent> {hasToken ? "重置 Token" : "Token"}
</DropdownMenu> </Button>
{hasToken && ( {hasToken && (
<ConfirmActionButton <ConfirmActionButton
size="sm" size="sm"
variant="outline" 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" title="撤销这个探测 Token"
description="撤销后探测 Agent 停止上报。" description="撤销后探测 Agent 停止上报。"
confirmLabel="撤销 Token" confirmLabel="撤销 Token"
successMessage="探测 Token 已撤销" successMessage="探测 Token 已撤销"
errorMessage="撤销失败" errorMessage="撤销失败"
onConfirm={() => revokeAgentToken(node.id)} onConfirm={() => revokeAgentToken(node.id)}
onSuccess={() => {
setHasToken(false);
router.refresh();
}}
> >
<ShieldOff className="size-3.5" />
Token Token
</ConfirmActionButton> </ConfirmActionButton>
)} )}
<ConfirmActionButton <ConfirmActionButton
size="sm" size="sm"
variant="destructive" variant="outline"
className="border-destructive/25 text-destructive hover:bg-destructive/10 hover:text-destructive"
title="删除这个节点?" title="删除这个节点?"
description="会清理节点、入口和探测数据。" description="会清理节点、入口和探测数据。"
confirmLabel="删除节点" confirmLabel="删除节点"
successMessage="节点已删除" successMessage="节点已删除"
errorMessage="删除失败" errorMessage="删除失败"
onConfirm={() => deleteNode(node.id)} onConfirm={() => deleteNode(node.id)}
onSuccess={() => {
if (deleteRedirectHref) router.push(deleteRedirectHref);
else router.refresh();
}}
> >
<Trash2 className="size-3.5" />
</ConfirmActionButton> </ConfirmActionButton>
@@ -175,6 +213,6 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Plus, Server, Pencil } from "lucide-react";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -9,6 +10,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -35,6 +37,10 @@ export function NodeForm({
}) { }) {
const isEdit = Boolean(node); const isEdit = Boolean(node);
const [open, setOpen] = useState(false); 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) { async function handleCreate(formData: FormData) {
try { try {
@@ -63,46 +69,68 @@ export function NodeForm({
<DialogTrigger <DialogTrigger
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />} render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
> >
{isEdit ? <Pencil className="size-3.5" /> : <Plus className="size-4" />}
{triggerLabel || (isEdit ? "编辑" : "添加节点")} {triggerLabel || (isEdit ? "编辑" : "添加节点")}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle> <div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<DialogDescription> 3x-ui </DialogDescription> <Server className="size-4" />
</div>
<DialogTitle>{isEdit ? "编辑节点" : "添加节点"}</DialogTitle>
<DialogDescription> 3x-ui </DialogDescription>
</DialogHeader> </DialogHeader>
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
<div className="grid gap-3 sm:grid-cols-2"> <form action={isEdit ? handleEdit : handleCreate} className="space-y-5">
<div> <div className="rounded-lg border border-border bg-muted/20 p-4">
<Label></Label> <div className="mb-3 text-sm font-semibold"></div>
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" /> <div className="grid gap-4 sm:grid-cols-2">
</div> <div className="space-y-2">
<div> <Label htmlFor={nameId}></Label>
<Label>3x-ui </Label> <Input id={nameId} name="name" defaultValue={node?.name ?? ""} placeholder="HK-01" />
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required /> </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> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="rounded-lg border border-border bg-muted/20 p-4">
<div> <div className="mb-3 text-sm font-semibold"></div>
<Label></Label> <div className="grid gap-4 sm:grid-cols-2">
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required /> <div className="space-y-2">
</div> <Label htmlFor={panelUsernameId}></Label>
<div> <Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
<Label></Label> </div>
<Input <div className="space-y-2">
name="panelPassword" <Label htmlFor={panelPasswordId}></Label>
type="password" <Input
placeholder={isEdit ? "留空不变" : "面板密码"} id={panelPasswordId}
required={!isEdit} name="panelPassword"
autoComplete="new-password" type="password"
/> placeholder={isEdit ? "留空不变" : "面板密码"}
required={!isEdit}
autoComplete="new-password"
/>
</div>
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">使 Token 3x-ui API</p> <DialogFooter className="-mx-6 -mb-6">
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}> <Button type="button" variant="outline" size="lg" onClick={() => setOpen(false)}>
{isEdit ? "保存并同步入站" : "创建并同步入站"}
</PendingSubmitButton> </Button>
<PendingSubmitButton size="lg" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存并同步" : "创建并同步"}
</PendingSubmitButton>
</DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -160,13 +160,13 @@ export function PaymentConfigItem({
return ( return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center"> <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">
<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">
<CreditCard className="size-4" /> <CreditCard className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3> <h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>} {displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div> </div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p> <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 ( return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center"> <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 <input
form={batchFormId} form={batchFormId}
type="checkbox" type="checkbox"
name="planIds" name="planIds"
value={plan.id} value={plan.id}
aria-label={`选择套餐 ${plan.name}`} 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" /> <Icon className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold tracking-tight">{plan.name}</h3> <h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">{plan.name}</h3>
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}> <StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
{plan.type === "PROXY" ? "代理" : "流媒体"} {plan.type === "PROXY" ? "代理" : "流媒体"}
</StatusBadge> </StatusBadge>