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:
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
{node.inbounds.length} 个
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
<InboundsTab node={node} />
|
<InboundsTab node={node} />
|
||||||
</TabsContent>
|
</section>
|
||||||
</Tabs>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="surface-card rounded-xl p-5">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无已同步入站"
|
title="暂无已同步入站"
|
||||||
description="在 3x-ui 创建后回到节点列表同步。"
|
description="同步节点后会显示可售入口。"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{node.inbounds.map((inbound) => (
|
{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">
|
const network = streamValue(inbound.streamSettings, "network");
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
const security = streamValue(inbound.streamSettings, "security");
|
||||||
<Waypoints className="size-4 shrink-0 text-primary" />
|
|
||||||
|
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
|
<InboundDisplayNameForm
|
||||||
inboundId={inbound.id}
|
inboundId={inbound.id}
|
||||||
defaultValue={getDisplayName(inbound)}
|
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>
|
</div>
|
||||||
<div className="flex items-center gap-2 lg:justify-end">
|
|
||||||
<Badge variant="secondary">{inbound.protocol}</Badge>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline">:{inbound.port}</Badge>
|
<InboundMeta label="客户端" value={inbound.clients.length} />
|
||||||
|
{network && <InboundMeta label="传输" value={network} />}
|
||||||
|
{security && <InboundMeta label="安全" value={security} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-start xl:justify-end">
|
||||||
<InboundDeleteButton inboundId={inbound.id} />
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: "icon" })}
|
className={buttonVariants({ variant: "ghost", size: "sm", className: "w-fit" })}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
<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"}>
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
{getNodeStatusLabel(node.status)}
|
{getNodeStatusLabel(node.status)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
}
|
</div>
|
||||||
className="flex-1"
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 handleGenerateToken() {
|
async function handleSync() {
|
||||||
try {
|
setSyncing(true);
|
||||||
const token = await generateAgentToken(node.id);
|
|
||||||
setPlainToken(token);
|
|
||||||
setInstallCommand(buildInstallCommand(token, siteUrl));
|
|
||||||
setTokenDialogOpen(true);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(getErrorMessage(error, "生成 Token 失败"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await testNodeConnection(node.id);
|
const res = await testNodeConnection(node.id);
|
||||||
if (res.success) toast.success(res.message);
|
if (res.success) toast.success(res.message);
|
||||||
else toast.error(getErrorMessage(res.message, "节点测试失败"));
|
else toast.error(getErrorMessage(res.message, "节点测试失败"));
|
||||||
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(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 (
|
||||||
|
<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")} />
|
||||||
</DropdownMenuItem>
|
{syncing ? "同步中" : "同步"}
|
||||||
<DropdownMenuItem onClick={handleGenerateToken}>
|
</Button>
|
||||||
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
|
||||||
</DropdownMenuItem>
|
<Button
|
||||||
</DropdownMenuContent>
|
type="button"
|
||||||
</DropdownMenu>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleGenerateToken()}
|
||||||
|
disabled={tokenLoading}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-3.5" />
|
||||||
|
{hasToken ? "重置 Token" : "Token"}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,33 +69,50 @@ 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" />
|
||||||
</DialogHeader>
|
</div>
|
||||||
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
|
<DialogTitle>{isEdit ? "编辑节点" : "添加节点"}</DialogTitle>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<DialogDescription>连接 3x-ui 面板并同步可售入站。</DialogDescription>
|
||||||
<div>
|
</DialogHeader>
|
||||||
<Label>节点名称</Label>
|
|
||||||
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" />
|
<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>
|
|
||||||
<Label>3x-ui 面板地址</Label>
|
|
||||||
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
|
|
||||||
</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">
|
||||||
|
<Label htmlFor={panelUsernameId}>面板用户名</Label>
|
||||||
|
<Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>面板密码</Label>
|
<Label htmlFor={panelPasswordId}>面板密码</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id={panelPasswordId}
|
||||||
name="panelPassword"
|
name="panelPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={isEdit ? "留空不变" : "面板密码"}
|
placeholder={isEdit ? "留空不变" : "面板密码"}
|
||||||
@@ -98,11 +121,16 @@ export function NodeForm({
|
|||||||
/>
|
/>
|
||||||
</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 ? "保存并同步入站" : "创建并同步入站"}
|
取消
|
||||||
|
</Button>
|
||||||
|
<PendingSubmitButton size="lg" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||||
|
{isEdit ? "保存并同步" : "创建并同步"}
|
||||||
</PendingSubmitButton>
|
</PendingSubmitButton>
|
||||||
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user