mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Waypoints } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { InboundDeleteButton } from "../../../inbound-delete-button";
|
||||
import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
|
||||
import type { NodeDetail } from "../../node-detail-data";
|
||||
|
||||
function getDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||
const settings = inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return inbound.tag;
|
||||
}
|
||||
|
||||
export function InboundsTab({ node }: { node: NodeDetail }) {
|
||||
if (node.inbounds.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无已同步入站"
|
||||
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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="grid gap-3">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<Card key={inbound.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Waypoints className="size-4 shrink-0 text-primary" />
|
||||
<CardTitle className="text-sm">
|
||||
<InboundDisplayNameForm
|
||||
inboundId={inbound.id}
|
||||
defaultValue={getDisplayName(inbound)}
|
||||
/>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{inbound.protocol}</Badge>
|
||||
<Badge variant="outline">:{inbound.port}</Badge>
|
||||
<InboundDeleteButton inboundId={inbound.id} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-x-6 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const nodeDetailInclude = {
|
||||
inbounds: {
|
||||
where: { isActive: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
clients: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.NodeServerInclude;
|
||||
|
||||
export type NodeDetail = Prisma.NodeServerGetPayload<{
|
||||
include: typeof nodeDetailInclude;
|
||||
}>;
|
||||
|
||||
export async function getNodeDetail(id: string): Promise<NodeDetail> {
|
||||
const node = await prisma.nodeServer.findUnique({
|
||||
where: { id },
|
||||
include: nodeDetailInclude,
|
||||
});
|
||||
if (!node) notFound();
|
||||
return node;
|
||||
}
|
||||
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getNodeDetail } from "./node-detail-data";
|
||||
import { NodeDetailTabs } from "./_components/node-detail-tabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "节点详情",
|
||||
};
|
||||
|
||||
export default async function NodeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const node = await getNodeDetail(id);
|
||||
|
||||
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"}>
|
||||
{node.status}
|
||||
</StatusBadge>
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NodeDetailTabs node={node} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Server, 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">3x-ui</span>
|
||||
<span>{node.panelUrl || "未配置面板"}</span>
|
||||
{node.agentToken && <span>探测 Token: 已启用</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 pb-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
form={NODE_BATCH_FORM_ID}
|
||||
type="checkbox"
|
||||
name="nodeIds"
|
||||
value={node.id}
|
||||
aria-label={`选择节点 ${node.name}`}
|
||||
className="mt-3 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">
|
||||
<Server className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg">
|
||||
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
||||
{node.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||
{node.status}
|
||||
</StatusBadge>
|
||||
<NodeForm
|
||||
node={{
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
panelUrl: node.panelUrl,
|
||||
panelUsername: node.panelUsername,
|
||||
panelPassword: node.panelPassword,
|
||||
}}
|
||||
triggerLabel="编辑"
|
||||
triggerVariant="outline"
|
||||
/>
|
||||
<NodeActions
|
||||
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
||||
siteUrl={siteUrl}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<PanelInfoBar node={node} />
|
||||
{node.inbounds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<div
|
||||
key={inbound.id}
|
||||
className="flex min-w-72 flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 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>
|
||||
<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">暂无已同步入站,请在 3x-ui 创建入站后点击同步</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteUrl: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
|
||||
<BatchActionButton>批量同步入站</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
<div className="grid gap-5">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
||||
action={<NodeForm triggerLabel="添加节点" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getInboundDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||
const settings = inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
|
||||
return inbound.tag;
|
||||
}
|
||||
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { deleteInbound } from "@/actions/admin/nodes";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
|
||||
export function InboundDeleteButton({
|
||||
inboundId,
|
||||
}: {
|
||||
inboundId: string;
|
||||
}) {
|
||||
return (
|
||||
<ConfirmActionButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-destructive hover:text-destructive"
|
||||
title="删除这个线路入口?"
|
||||
description="这里只会移除本地同步记录,不会删除 3x-ui 面板中的入站。请确认没有套餐仍依赖它。"
|
||||
confirmLabel="删除入口"
|
||||
successMessage="线路入口已删除"
|
||||
errorMessage="删除线路入口失败"
|
||||
onConfirm={() => deleteInbound(inboundId)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
);
|
||||
}
|
||||
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { updateInboundDisplayName } from "@/actions/admin/nodes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function InboundDisplayNameForm({
|
||||
inboundId,
|
||||
defaultValue,
|
||||
}: {
|
||||
inboundId: string;
|
||||
defaultValue: string;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateInboundDisplayName(inboundId, formData);
|
||||
toast.success("前台名称已更新");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Input
|
||||
name="displayName"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="例如 悉尼 · 日常优选"
|
||||
className="h-8 min-h-8 rounded-xl px-3 text-xs"
|
||||
/>
|
||||
<Button type="submit" size="xs" variant="outline" disabled={saving}>
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Terminal } 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,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeActionValue {
|
||||
id: string;
|
||||
name: string;
|
||||
agentToken: string | null;
|
||||
}
|
||||
|
||||
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/main/scripts/install-jboard-agent.sh";
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function getServerUrl() {
|
||||
if (typeof window === "undefined") return "";
|
||||
const { protocol, host, hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return "";
|
||||
return `${protocol}//${host}`;
|
||||
}
|
||||
|
||||
function buildInstallCommand(token: string, siteUrl: string | null) {
|
||||
const serverUrl = siteUrl || getServerUrl() || "https://你的域名";
|
||||
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 }) {
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||
const [plainToken, setPlainToken] = useState("");
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const hasToken = !!node.agentToken;
|
||||
|
||||
async function handleGenerateToken() {
|
||||
try {
|
||||
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 {
|
||||
const res = await testNodeConnection(node.id);
|
||||
if (res.success) toast.success(res.message);
|
||||
else toast.error(res.message);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "测试失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试并同步入站
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleGenerateToken}>
|
||||
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasToken && (
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title="撤销这个探测 Token?"
|
||||
description="撤销后,延迟和线路探测程序将无法继续上报数据。"
|
||||
confirmLabel="撤销 Token"
|
||||
successMessage="探测 Token 已撤销"
|
||||
errorMessage="撤销失败"
|
||||
onConfirm={() => revokeAgentToken(node.id)}
|
||||
>
|
||||
撤销 Token
|
||||
</ConfirmActionButton>
|
||||
)}
|
||||
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这个节点?"
|
||||
description="节点、线路入口和相关探测数据会被清理。请确认没有套餐仍依赖它。"
|
||||
confirmLabel="删除节点"
|
||||
successMessage="节点已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteNode(node.id)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<KeyRound className="size-3.5" /> PROBE TOKEN
|
||||
</div>
|
||||
<DialogTitle>探测 Token — {node.name}</DialogTitle>
|
||||
<DialogDescription>请立即复制 Token 或一键安装命令,关闭后将无法再次查看。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground">探测 Token</div>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||
{plainToken}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(plainToken);
|
||||
toast.success("Token 已复制");
|
||||
}}
|
||||
>
|
||||
复制 Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Terminal className="size-3.5" /> 一键安装探测 Agent
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||
{installCommand}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(installCommand);
|
||||
toast.success("安装命令已复制");
|
||||
}}
|
||||
>
|
||||
复制一键安装命令
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!siteUrl && (
|
||||
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200">
|
||||
建议先到系统设置填写站点域名,否则从本地地址打开后台时命令会带本机地址。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
此 Agent 仅用于 `/api/agent/latency` 和 `/api/agent/trace` 探测上报;节点客户端开通已改由 3x-ui 面板 API 处理。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createNode, updateNode } from "@/actions/admin/nodes";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeFormValue {
|
||||
id: string;
|
||||
name: string;
|
||||
panelUrl: string | null;
|
||||
panelUsername: string | null;
|
||||
panelPassword: string | null;
|
||||
}
|
||||
|
||||
export function NodeForm({
|
||||
node,
|
||||
triggerLabel,
|
||||
triggerVariant = "default",
|
||||
}: {
|
||||
node?: NodeFormValue;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const isEdit = Boolean(node);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleCreate(formData: FormData) {
|
||||
try {
|
||||
const result = await createNode(formData);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.warning(result.message);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(formData: FormData) {
|
||||
try {
|
||||
const result = await updateNode(node!.id, formData);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.warning(result.message);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||
>
|
||||
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存后会登录 3x-ui 并同步面板中的入站线路;入站请在 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 />
|
||||
</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" defaultValue={node?.panelPassword ?? ""} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
延迟和线路探测仍使用探测 Token;节点开通、暂停、删除客户端均回归 3x-ui 面板 API。
|
||||
</p>
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
{isEdit ? "保存并同步入站" : "创建并同步入站"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import { getConfiguredSiteUrl } from "@/services/site-url";
|
||||
|
||||
const nodeInclude = {
|
||||
_count: { select: { inbounds: true } },
|
||||
inbounds: {
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
protocol: true,
|
||||
port: true,
|
||||
tag: true,
|
||||
settings: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
},
|
||||
} satisfies Prisma.NodeServerInclude;
|
||||
|
||||
export type NodeServerRow = Prisma.NodeServerGetPayload<{
|
||||
include: typeof nodeInclude;
|
||||
}>;
|
||||
|
||||
export async function getNodeServers(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { status } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" as const } },
|
||||
{ panelUrl: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.NodeServerWhereInput;
|
||||
|
||||
const [nodes, total, siteUrl] = await Promise.all([
|
||||
prisma.nodeServer.findMany({
|
||||
where,
|
||||
include: nodeInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.nodeServer.count({ where }),
|
||||
getConfiguredSiteUrl(),
|
||||
]);
|
||||
|
||||
return { nodes, total, page, pageSize, filters: { q, status }, siteUrl };
|
||||
}
|
||||
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { NodeForm } from "./node-form";
|
||||
import { NodeCardList } from "./_components/node-card-list";
|
||||
import { getNodeServers } from "./nodes-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "节点管理",
|
||||
description: "维护节点面板连接与可售入站配置。",
|
||||
};
|
||||
|
||||
export default async function NodesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { nodes, total, page, pageSize, filters, siteUrl } = await getNodeServers(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="基础设施"
|
||||
title="节点管理"
|
||||
actions={<NodeForm />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索节点名、主机或面板地址"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "active", value: "active" },
|
||||
{ label: "inactive", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<NodeCardList nodes={nodes} siteUrl={siteUrl} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user