diff --git a/src/actions/admin/nodes.ts b/src/actions/admin/nodes.ts
index bae1afc..87de394 100644
--- a/src/actions/admin/nodes.ts
+++ b/src/actions/admin/nodes.ts
@@ -105,6 +105,7 @@ export async function updateNode(id: string, formData: FormData) {
message: `更新 3x-ui 节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${id}`);
return {
...result,
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
@@ -140,6 +141,7 @@ export async function testNodeConnection(id: string) {
message: `测试 3x-ui 节点 ${server.name}:${result.message}`,
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${id}`);
return result;
}
@@ -172,7 +174,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id },
- include: { server: { select: { name: true } } },
+ include: { server: { select: { id: true, name: true } } },
});
await prisma.nodeInbound.update({
@@ -190,6 +192,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${inbound.server.id}`);
revalidatePath("/store");
}
@@ -210,6 +213,7 @@ export async function deleteInbound(id: string) {
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${inbound.server.id}`);
}
export async function generateAgentToken(nodeId: string) {
@@ -237,6 +241,7 @@ export async function generateAgentToken(nodeId: string) {
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${nodeId}`);
return plainToken;
}
@@ -262,4 +267,5 @@ export async function revokeAgentToken(nodeId: string) {
});
revalidatePath("/admin/nodes");
+ revalidatePath(`/admin/nodes/${nodeId}`);
}
diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx
index 66489ab..40b33f1 100644
--- a/src/app/(admin)/admin/commerce/page.tsx
+++ b/src/app/(admin)/admin/commerce/page.tsx
@@ -111,11 +111,11 @@ export default async function AdminCommercePage() {
{coupons.map((coupon) => (
-
-
+
+
-
-
{coupon.name}
+
{coupon.code}
@@ -145,11 +145,11 @@ export default async function AdminCommercePage() {
{promotions.map((rule) => (
-
-
+
+
-
-
{rule.name}
+
满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}
diff --git a/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx b/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx
index bcc1e57..91c6bd0 100644
--- a/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx
+++ b/src/app/(admin)/admin/nodes/[id]/_components/node-detail-tabs.tsx
@@ -1,20 +1,22 @@
"use client";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { StatusBadge } from "@/components/shared/status-badge";
import type { NodeDetail } from "../node-detail-data";
import { InboundsTab } from "./tabs/inbounds-tab";
export function NodeDetailTabs({ node }: { node: NodeDetail }) {
return (
-
-
-
- 3x-ui 入站 ({node.inbounds.length})
-
-
-
-
-
-
+
+
+
+
0 ? "info" : "neutral"}>
+ {node.inbounds.length} 个
+
+
+
+
);
}
diff --git a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx
index f5de78f..6799499 100644
--- a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx
+++ b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx
@@ -3,6 +3,7 @@
import { Waypoints } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/page-shell";
+import { StatusBadge } from "@/components/shared/status-badge";
import { InboundDeleteButton } from "../../../inbound-delete-button";
import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
import type { NodeDetail } from "../../node-detail-data";
@@ -16,52 +17,77 @@ function getDisplayName(inbound: { tag: string; settings: unknown }) {
return inbound.tag;
}
+function streamValue(settings: unknown, key: string) {
+ if (!settings || typeof settings !== "object") return null;
+ const value = (settings as Record
)[key];
+ if (typeof value !== "string" || !value.trim()) return null;
+ return value;
+}
+
+function InboundMeta({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
export function InboundsTab({ node }: { node: NodeDetail }) {
if (node.inbounds.length === 0) {
return (
-
+
+
+
);
}
return (
-
-
- 入站由 3x-ui 维护,这里只调整前台名称。
-
-
- {node.inbounds.map((inbound) => (
-
-
-
-
+
+ {node.inbounds.map((inbound) => {
+ const network = streamValue(inbound.streamSettings, "network");
+ const security = streamValue(inbound.streamSettings, "security");
+
+ return (
+
+
+
+
+
+
+
+ {inbound.protocol}
+ :{inbound.port}
+ {inbound.tag}
+
+
+
-
-
客户端: {inbound.clients.length}
- {inbound.streamSettings && typeof inbound.streamSettings === "object" && (
- <>
- {(inbound.streamSettings as Record
).network && (
- 传输: {String((inbound.streamSettings as Record).network)}
- )}
- {(inbound.streamSettings as Record).security && (
- 安全: {String((inbound.streamSettings as Record).security)}
- )}
- >
- )}
+
+
+
+ {network && }
+ {security && }
-
-
{inbound.protocol}
-
:{inbound.port}
+
+
-
- ))}
-
+
+ );
+ })}
);
}
diff --git a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
index 3b6eb53..9bb0f49 100644
--- a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
+++ b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
@@ -7,7 +7,9 @@ const nodeDetailSelect = {
id: true,
name: true,
panelUrl: true,
+ panelUsername: true,
status: true,
+ agentToken: true,
inbounds: {
where: { isActive: true },
orderBy: { updatedAt: "desc" },
@@ -32,6 +34,7 @@ export async function getNodeDetail(id: string): Promise {
return {
...node,
+ agentToken: node.agentToken ? "configured" : null,
inbounds: node.inbounds.map((inbound) => ({
...inbound,
settings: sanitizeInboundSettings(inbound.settings),
diff --git a/src/app/(admin)/admin/nodes/[id]/page.tsx b/src/app/(admin)/admin/nodes/[id]/page.tsx
index c4c9393..aa50b35 100644
--- a/src/app/(admin)/admin/nodes/[id]/page.tsx
+++ b/src/app/(admin)/admin/nodes/[id]/page.tsx
@@ -1,46 +1,122 @@
import type { Metadata } from "next";
+import type { ReactNode } from "react";
import Link from "next/link";
-import { ArrowLeft } from "lucide-react";
-import { PageHeader, PageShell } from "@/components/shared/page-shell";
+import { ArrowLeft, KeyRound, Server, UserRound, Waypoints } from "lucide-react";
+import { PageShell } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button";
import { getNodeStatusLabel } from "@/lib/domain-labels";
+import { getConfiguredSiteUrl } from "@/services/site-url";
import { getNodeDetail } from "./node-detail-data";
import { NodeDetailTabs } from "./_components/node-detail-tabs";
+import { NodeActions } from "../node-actions";
+import { NodeForm } from "../node-form";
export const metadata: Metadata = {
title: "节点详情",
};
+function DetailMetric({
+ icon,
+ label,
+ value,
+}: {
+ icon: ReactNode;
+ label: string;
+ value: ReactNode;
+}) {
+ return (
+
+ );
+}
+
export default async function NodeDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
- const node = await getNodeDetail(id);
+ const [node, siteUrl] = await Promise.all([getNodeDetail(id), getConfiguredSiteUrl()]);
return (
-
-
-
-
-
- {getNodeStatusLabel(node.status)}
-
- }
- className="flex-1"
- />
-
+
+
+ 返回节点
+
+
+
+
+
+
+
+
+
+
+
{node.name}
+
+ {getNodeStatusLabel(node.status)}
+
+
+
+ {node.panelUrl || "未配置面板"}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ label="面板类型"
+ value="3x-ui"
+ />
+ }
+ label="面板账号"
+ value={node.panelUsername || "未配置"}
+ />
+ }
+ label="已同步入站"
+ value={`${node.inbounds.length} 个`}
+ />
+ }
+ label="探测 Token"
+ value={node.agentToken ? "已启用" : "未生成"}
+ />
+
+
diff --git a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx
index 76a5c7e..987584d 100644
--- a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx
+++ b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx
@@ -1,46 +1,89 @@
-import { Server, Waypoints } from "lucide-react";
+import { KeyRound, Server, UserRound, Waypoints } from "lucide-react";
import Link from "next/link";
import { batchTestNodeConnections } from "@/actions/admin/nodes";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { getNodeStatusLabel } from "@/lib/domain-labels";
-import { InboundDeleteButton } from "../inbound-delete-button";
-import { InboundDisplayNameForm } from "../inbound-display-name-form";
import { NodeActions } from "../node-actions";
import { NodeForm } from "../node-form";
import type { NodeServerRow } from "../nodes-data";
const NODE_BATCH_FORM_ID = "node-batch-form";
-function PanelInfoBar({ node }: { node: NodeServerRow }) {
+function NodeInlineMeta({ node }: { node: NodeServerRow }) {
return (
-
-
3x-ui
-
{node.panelUrl || "未配置面板"}
- {node.agentToken &&
探测 Token: 已启用}
+
+ {node.panelUrl || "未配置面板"}
+
+
+ {node.panelUsername || "未配置账号"}
+
+
+
+ {node.agentToken ? "Token 已启用" : "未生成 Token"}
+
+
+ );
+}
+
+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 (
+
+ 暂无已同步入站
+
+ );
+ }
+
+ return (
+
+
+
可售入站
+
{node._count.inbounds} 个
+
+
+ {preview.map((inbound) => (
+
+
+ {inbound.protocol}:{inbound.port}
+ {getInboundDisplayName(inbound)}
+
+ ))}
+ {hiddenCount > 0 && (
+
+ +{hiddenCount}
+
+ )}
+
);
}
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
return (
-
-
+
+
-
+
-
-
+
+
{node.name}
@@ -49,38 +92,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
{getNodeStatusLabel(node.status)}
-
- {node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
-
+
-
-
- {node.inbounds.length > 0 ? (
-
- {node.inbounds.map((inbound) => (
-
-
- {inbound.protocol} · {inbound.port}
-
-
-
- ))}
-
- ) : (
-
- 暂无已同步入站。
-
- )}
+
-
+
);
}
diff --git a/src/app/(admin)/admin/nodes/inbound-delete-button.tsx b/src/app/(admin)/admin/nodes/inbound-delete-button.tsx
index b592640..149eac3 100644
--- a/src/app/(admin)/admin/nodes/inbound-delete-button.tsx
+++ b/src/app/(admin)/admin/nodes/inbound-delete-button.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Trash2 } from "lucide-react";
import { deleteInbound } from "@/actions/admin/nodes";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
@@ -20,6 +21,7 @@ export function InboundDeleteButton({
errorMessage="删除线路入口失败"
onConfirm={() => deleteInbound(inboundId)}
>
+
删除
);
diff --git a/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx b/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
index 9ad00c1..b2059fc 100644
--- a/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
+++ b/src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState } from "react";
+import { useId, useState } from "react";
+import { Save } from "lucide-react";
import { updateInboundDisplayName } from "@/actions/admin/nodes";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,6 +15,7 @@ export function InboundDisplayNameForm({
inboundId: string;
defaultValue: string;
}) {
+ const inputId = useId();
const [saving, setSaving] = useState(false);
async function handleSubmit(formData: FormData) {
@@ -30,13 +32,18 @@ export function InboundDisplayNameForm({
return (
diff --git a/src/app/(admin)/admin/nodes/node-actions.tsx b/src/app/(admin)/admin/nodes/node-actions.tsx
index 6f312c2..4806965 100644
--- a/src/app/(admin)/admin/nodes/node-actions.tsx
+++ b/src/app/(admin)/admin/nodes/node-actions.tsx
@@ -1,15 +1,10 @@
"use client";
import { useState } from "react";
-import { KeyRound, Terminal } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { KeyRound, RefreshCw, ShieldOff, Terminal, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@@ -19,6 +14,7 @@ import {
} from "@/components/ui/dialog";
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
import { getErrorMessage } from "@/lib/errors";
+import { cn } from "@/lib/utils";
import { toast } from "sonner";
interface NodeActionValue {
@@ -45,72 +41,114 @@ function buildInstallCommand(token: string, siteUrl: string | null) {
return `curl -fsSL ${INSTALL_SCRIPT_URL} | SERVER_URL=${shellQuote(serverUrl)} AUTH_TOKEN=${shellQuote(token)} bash`;
}
-export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl: string | null }) {
+export function NodeActions({
+ node,
+ siteUrl,
+ deleteRedirectHref,
+}: {
+ node: NodeActionValue;
+ siteUrl: string | null;
+ deleteRedirectHref?: string;
+}) {
+ const router = useRouter();
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [plainToken, setPlainToken] = useState("");
const [installCommand, setInstallCommand] = useState("");
- const hasToken = !!node.agentToken;
+ const [hasToken, setHasToken] = useState(!!node.agentToken);
+ const [syncing, setSyncing] = useState(false);
+ const [tokenLoading, setTokenLoading] = useState(false);
+
+ async function handleSync() {
+ setSyncing(true);
+ try {
+ const res = await testNodeConnection(node.id);
+ if (res.success) toast.success(res.message);
+ else toast.error(getErrorMessage(res.message, "节点测试失败"));
+ router.refresh();
+ } catch (error) {
+ toast.error(getErrorMessage(error, "测试失败"));
+ } finally {
+ setSyncing(false);
+ }
+ }
async function handleGenerateToken() {
+ setTokenLoading(true);
try {
const token = await generateAgentToken(node.id);
+ setHasToken(true);
setPlainToken(token);
setInstallCommand(buildInstallCommand(token, siteUrl));
setTokenDialogOpen(true);
+ router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成 Token 失败"));
+ } finally {
+ setTokenLoading(false);
}
}
return (
- <>
-
- }>...
-
- {
- try {
- const res = await testNodeConnection(node.id);
- if (res.success) toast.success(res.message);
- else toast.error(getErrorMessage(res.message, "节点测试失败"));
- } catch (error) {
- toast.error(getErrorMessage(error, "测试失败"));
- }
- }}
- >
- 测试并同步入站
-
-
- {hasToken ? "重新生成探测 Token" : "生成探测 Token"}
-
-
-
+
+
+
+
{hasToken && (
revokeAgentToken(node.id)}
+ onSuccess={() => {
+ setHasToken(false);
+ router.refresh();
+ }}
>
+
撤销 Token
)}
deleteNode(node.id)}
+ onSuccess={() => {
+ if (deleteRedirectHref) router.push(deleteRedirectHref);
+ else router.refresh();
+ }}
>
+
删除
@@ -175,6 +213,6 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
- >
+
);
}
diff --git a/src/app/(admin)/admin/nodes/node-form.tsx b/src/app/(admin)/admin/nodes/node-form.tsx
index 6bbdd86..2f73f29 100644
--- a/src/app/(admin)/admin/nodes/node-form.tsx
+++ b/src/app/(admin)/admin/nodes/node-form.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { Plus, Server, Pencil } from "lucide-react";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,6 +10,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -35,6 +37,10 @@ export function NodeForm({
}) {
const isEdit = Boolean(node);
const [open, setOpen] = useState(false);
+ const nameId = isEdit ? `node-name-${node!.id}` : "node-name";
+ const panelUrlId = isEdit ? `node-panel-url-${node!.id}` : "node-panel-url";
+ const panelUsernameId = isEdit ? `node-panel-username-${node!.id}` : "node-panel-username";
+ const panelPasswordId = isEdit ? `node-panel-password-${node!.id}` : "node-panel-password";
async function handleCreate(formData: FormData) {
try {
@@ -63,46 +69,68 @@ export function NodeForm({
}
>
+ {isEdit ? : }
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
-
+
- {isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}
- 保存后登录 3x-ui 并同步入站。
+
+
+
+ {isEdit ? "编辑节点" : "添加节点"}
+ 连接 3x-ui 面板并同步可售入站。
-