Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

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

View File

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

View 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;
}

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

View 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;
}

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

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

View 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>
</>
);
}

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

View 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 };
}

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