feat: 支持自定义订阅节点并兼容现有数据库
This commit is contained in:
@@ -7,12 +7,16 @@ import { DashboardCharts } from "./dashboard-charts";
|
||||
|
||||
function getNodePill(node: {
|
||||
enabled: number;
|
||||
node_type: "hy2" | "custom";
|
||||
last_error_message: string | null;
|
||||
}) {
|
||||
if (!node.enabled) {
|
||||
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||||
}
|
||||
|
||||
if (node.node_type === "custom")
|
||||
return { label: "自定义订阅", className: "status-pill status-pill--ink" };
|
||||
|
||||
if (node.last_error_message) {
|
||||
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||||
}
|
||||
@@ -66,7 +70,7 @@ export default async function DashboardPage() {
|
||||
</section>
|
||||
|
||||
<DashboardCharts
|
||||
nodeHealth={data.nodeHealth}
|
||||
nodeHealth={data.nodeHealth.filter((node) => node.node_type === "hy2")}
|
||||
trafficTrend={data.trafficTrend}
|
||||
trafficBuckets={data.trafficBuckets}
|
||||
trafficUserOptions={data.trafficUserOptions}
|
||||
@@ -103,11 +107,15 @@ export default async function DashboardPage() {
|
||||
</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||||
</p>
|
||||
<p className="muted">
|
||||
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{node.node_type === "custom"
|
||||
? "仅订阅 · 不参与轮询"
|
||||
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
|
||||
</p>
|
||||
{node.node_type === "hy2" && (
|
||||
<p className="muted">
|
||||
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||
</p>
|
||||
)}
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
|
||||
+54
-12
@@ -1,69 +1,110 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
CustomProxyValidationError,
|
||||
parseCustomProxyYaml,
|
||||
} from "@/lib/custom-proxy";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import { createNode, deleteNode, updateNode } from "@/lib/store";
|
||||
import {
|
||||
createNode,
|
||||
deleteNode,
|
||||
normalizeNodeType,
|
||||
updateNode,
|
||||
} from "@/lib/store";
|
||||
|
||||
function boolFromForm(value: FormDataEntryValue | null) {
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
function validateCustomYaml(nodeType: string, formData: FormData) {
|
||||
if (nodeType !== "custom") return;
|
||||
try {
|
||||
parseCustomProxyYaml(String(formData.get("custom_config_yaml") ?? ""));
|
||||
} catch (error) {
|
||||
if (error instanceof CustomProxyValidationError)
|
||||
return { error: error.message };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
const nodeType = normalizeNodeType(
|
||||
String(formData.get("node_type") ?? "hy2"),
|
||||
);
|
||||
const validation = validateCustomYaml(nodeType, formData);
|
||||
if (validation) return validation;
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const clientName = String(formData.get("client_name") ?? "").trim();
|
||||
|
||||
await createNode({
|
||||
nodeType,
|
||||
slug: String(formData.get("slug") ?? "").trim(),
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
name,
|
||||
authToken: String(formData.get("auth_token") ?? "").trim() || null,
|
||||
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||
serverPort: Number(formData.get("server_port") ?? 443),
|
||||
clientName:
|
||||
String(formData.get("client_name") ?? "").trim() ||
|
||||
String(formData.get("name") ?? "").trim(),
|
||||
clientName: clientName || (nodeType === "hy2" ? name : ""),
|
||||
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||
customConfigYaml: String(formData.get("custom_config_yaml") ?? ""),
|
||||
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||
trafficStatsListen:
|
||||
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||
trafficStatsSecret:
|
||||
String(formData.get("traffic_stats_secret") ?? "").trim(),
|
||||
trafficStatsSecret: String(
|
||||
formData.get("traffic_stats_secret") ?? "",
|
||||
).trim(),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function updateNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
const nodeType = normalizeNodeType(
|
||||
String(formData.get("node_type") ?? "hy2"),
|
||||
);
|
||||
const validation = validateCustomYaml(nodeType, formData);
|
||||
if (validation) return validation;
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const clientName = String(formData.get("client_name") ?? "").trim();
|
||||
|
||||
await updateNode({
|
||||
id: Number(formData.get("id")),
|
||||
nodeType,
|
||||
slug: String(formData.get("slug") ?? "").trim(),
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
name,
|
||||
authToken: String(formData.get("auth_token") ?? "").trim(),
|
||||
hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "",
|
||||
serverHost: String(formData.get("server_host") ?? "").trim(),
|
||||
serverPort: Number(formData.get("server_port") ?? 443),
|
||||
clientName:
|
||||
String(formData.get("client_name") ?? "").trim() ||
|
||||
String(formData.get("name") ?? "").trim(),
|
||||
clientName: clientName || (nodeType === "hy2" ? name : ""),
|
||||
bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30),
|
||||
bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30),
|
||||
skipCertVerify: boolFromForm(formData.get("skip_cert_verify")),
|
||||
sni: String(formData.get("sni") ?? "").trim() || null,
|
||||
customConfigYaml: String(formData.get("custom_config_yaml") ?? ""),
|
||||
trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(),
|
||||
trafficStatsListen:
|
||||
String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999",
|
||||
trafficStatsSecret: String(formData.get("traffic_stats_secret") ?? "").trim(),
|
||||
trafficStatsSecret: String(
|
||||
formData.get("traffic_stats_secret") ?? "",
|
||||
).trim(),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
@@ -71,5 +112,6 @@ export async function deleteNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await deleteNode(Number(formData.get("id")));
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import type { Hy2NodeRow } from "@/lib/store";
|
||||
import { createNodeAction, updateNodeAction } from "./actions";
|
||||
|
||||
const example = `- name: "HK AnyTLS"
|
||||
type: anytls
|
||||
server: hk.example.com
|
||||
port: 443
|
||||
password: "your-password"
|
||||
sni: hk.example.com
|
||||
udp: true`;
|
||||
|
||||
export function CustomNodeForm({ node }: { node?: Hy2NodeRow }) {
|
||||
return (
|
||||
<ActionForm
|
||||
action={node ? updateNodeAction : createNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
{node && <input name="id" type="hidden" value={node.id} />}
|
||||
<input name="node_type" type="hidden" value="custom" />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<Input
|
||||
name="slug"
|
||||
defaultValue={node?.slug}
|
||||
placeholder="hk-anytls"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<Input
|
||||
name="name"
|
||||
defaultValue={node?.name}
|
||||
placeholder="香港 · AnyTLS"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={node ? Boolean(node.enabled) : true}
|
||||
/>
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<p className="muted full-span">
|
||||
仅写入订阅,不使用 HY2 鉴权和流量统计。订阅中的节点名称以 YAML 的 name
|
||||
为准。
|
||||
</p>
|
||||
<label className="field full-span">
|
||||
<span>节点 YAML</span>
|
||||
<textarea
|
||||
className="mono-textarea"
|
||||
name="custom_config_yaml"
|
||||
defaultValue={node?.custom_config_yaml}
|
||||
placeholder={example}
|
||||
rows={9}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
required
|
||||
aria-describedby={node ? `yaml-help-${node.id}` : "yaml-help-new"}
|
||||
/>
|
||||
</label>
|
||||
<div className="full-span stack-tight">
|
||||
<p
|
||||
className="muted text-xs"
|
||||
id={node ? `yaml-help-${node.id}` : "yaml-help-new"}
|
||||
>
|
||||
支持单个 proxy 对象、单节点列表,或 proxies
|
||||
包装;每条记录一个节点。保存后可在「订阅配置」中拖拽排序、加入分组。
|
||||
</p>
|
||||
<details className="config-collapse">
|
||||
<summary>查看 AnyTLS 示例</summary>
|
||||
<pre>{example}</pre>
|
||||
<div className="p-2">
|
||||
<CopyButton value={example} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<SubmitButton variant={node ? "outline" : "default"}>
|
||||
{node ? "保存自定义节点" : "创建自定义节点"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
);
|
||||
}
|
||||
+223
-173
@@ -1,3 +1,4 @@
|
||||
import { CustomNodeForm } from "./custom-node-form";
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
@@ -7,7 +8,12 @@ import { SubmitButton } from "@/components/submit-button";
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||
import {
|
||||
buildNodeAuthUrl,
|
||||
buildTrafficStatsUrl,
|
||||
getNodeProxyName,
|
||||
listNodes,
|
||||
} from "@/lib/store";
|
||||
|
||||
import {
|
||||
createNodeAction,
|
||||
@@ -17,6 +23,7 @@ import {
|
||||
|
||||
function getNodePills(node: {
|
||||
enabled: number;
|
||||
node_type: "hy2" | "custom";
|
||||
last_error_message: string | null;
|
||||
}) {
|
||||
return [
|
||||
@@ -24,9 +31,11 @@ function getNodePills(node: {
|
||||
label: node.enabled ? "已启用" : "已禁用",
|
||||
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
|
||||
},
|
||||
node.last_error_message
|
||||
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
||||
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
||||
node.node_type === "custom"
|
||||
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
|
||||
: node.last_error_message
|
||||
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
||||
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,7 +63,7 @@ export default async function NodesPage() {
|
||||
<details>
|
||||
<summary className="compact-create-summary">
|
||||
<div>
|
||||
<h3>新增节点</h3>
|
||||
<h3>新增 HY2 节点</h3>
|
||||
</div>
|
||||
<span className="status-pill status-pill--warm">展开</span>
|
||||
</summary>
|
||||
@@ -62,6 +71,7 @@ export default async function NodesPage() {
|
||||
action={createNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
<input type="hidden" name="node_type" value="hy2" />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<Input name="slug" placeholder="tokyo-01" required />
|
||||
@@ -169,6 +179,15 @@ export default async function NodesPage() {
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details>
|
||||
<summary className="compact-create-summary">
|
||||
<h3>新增自定义订阅节点</h3>
|
||||
</summary>
|
||||
<CustomNodeForm />
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>节点列表</h3>
|
||||
@@ -195,6 +214,8 @@ export default async function NodesPage() {
|
||||
</div>
|
||||
}
|
||||
items={nodes.map((node) => {
|
||||
const isCustom = node.node_type === "custom";
|
||||
const proxyName = getNodeProxyName(node);
|
||||
const authUrl = buildNodeAuthUrl(
|
||||
node.slug,
|
||||
node.auth_token,
|
||||
@@ -207,7 +228,7 @@ export default async function NodesPage() {
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
text: `${node.name} ${node.slug} ${node.server_host} ${node.client_name}`,
|
||||
text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : "HY2"}`,
|
||||
status: node.enabled ? "enabled" : "disabled",
|
||||
content: (
|
||||
<details key={node.id} className="entity-table__item">
|
||||
@@ -224,22 +245,32 @@ export default async function NodesPage() {
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-code-cell">
|
||||
{node.server_host}:{node.server_port}
|
||||
<small>hy2 {node.hy2_listen}</small>
|
||||
{isCustom
|
||||
? proxyName
|
||||
: `${node.server_host}:${node.server_port}`}
|
||||
<small>
|
||||
{isCustom ? "自定义订阅节点" : `hy2 ${node.hy2_listen}`}
|
||||
</small>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">
|
||||
{node.traffic_stats_url}
|
||||
<small>{node.traffic_stats_listen}</small>
|
||||
{isCustom ? "不轮询" : node.traffic_stats_url}
|
||||
<small>
|
||||
{isCustom ? "仅订阅" : node.traffic_stats_listen}
|
||||
</small>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>
|
||||
{node.last_online_users} / {node.last_stream_count}
|
||||
{isCustom
|
||||
? "—"
|
||||
: `${node.last_online_users} / ${node.last_stream_count}`}
|
||||
</strong>
|
||||
<span>用户 / 流</span>
|
||||
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
|
||||
</span>
|
||||
<span className="entity-muted-cell">
|
||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{node.last_error_at ? (
|
||||
{isCustom
|
||||
? "—"
|
||||
: formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{!isCustom && node.last_error_at ? (
|
||||
<small>
|
||||
错误 {formatLocalDateTime(node.last_error_at)}
|
||||
</small>
|
||||
@@ -251,169 +282,188 @@ export default async function NodesPage() {
|
||||
</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-grid">
|
||||
<div className="dense-code-line">
|
||||
<span>鉴权 URL</span>
|
||||
<div className="copy-line">
|
||||
<code>{authUrl}</code>
|
||||
<CopyButton value={authUrl} />
|
||||
{isCustom ? (
|
||||
<CustomNodeForm node={node} />
|
||||
) : (
|
||||
<>
|
||||
<div className="dense-code-grid">
|
||||
<div className="dense-code-line">
|
||||
<span>鉴权 URL</span>
|
||||
<div className="copy-line">
|
||||
<code>{authUrl}</code>
|
||||
<CopyButton value={authUrl} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dense-code-line">
|
||||
<span>自动推导 trafficStats</span>
|
||||
<code>{suggestedTrafficStatsUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dense-code-line">
|
||||
<span>自动推导 trafficStats</span>
|
||||
<code>{suggestedTrafficStatsUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionForm
|
||||
action={updateNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<Input name="slug" defaultValue={node.slug} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<Input name="name" defaultValue={node.name} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<Input
|
||||
name="server_host"
|
||||
defaultValue={node.server_host}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<Input
|
||||
name="hy2_listen"
|
||||
defaultValue={node.hy2_listen}
|
||||
placeholder=":443 或 0.0.0.0:443"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<Input
|
||||
name="server_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
defaultValue={node.server_port}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<Input
|
||||
name="client_name"
|
||||
defaultValue={node.client_name}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<Input
|
||||
name="auth_token"
|
||||
defaultValue={node.auth_token}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<Input
|
||||
name="bandwidth_up_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_up_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<Input
|
||||
name="bandwidth_down_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_down_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<Input name="sni" defaultValue={node.sni ?? ""} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="skip_cert_verify"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||
/>
|
||||
<span>跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<Input
|
||||
name="traffic_stats_listen"
|
||||
defaultValue={node.traffic_stats_listen}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<Input
|
||||
name="traffic_stats_url"
|
||||
defaultValue={node.traffic_stats_url}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<Input
|
||||
name="traffic_stats_secret"
|
||||
defaultValue={node.traffic_stats_secret}
|
||||
placeholder="留空保持当前值"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<Input
|
||||
name="poll_interval_seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
max="30"
|
||||
defaultValue={node.poll_interval_seconds}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.enabled)}
|
||||
/>
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<SubmitButton variant="outline">保存节点</SubmitButton>
|
||||
</ActionForm>
|
||||
<ActionForm
|
||||
action={updateNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<input name="node_type" type="hidden" value="hy2" />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<Input
|
||||
name="slug"
|
||||
defaultValue={node.slug}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<Input
|
||||
name="name"
|
||||
defaultValue={node.name}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<Input
|
||||
name="server_host"
|
||||
defaultValue={node.server_host}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<Input
|
||||
name="hy2_listen"
|
||||
defaultValue={node.hy2_listen}
|
||||
placeholder=":443 或 0.0.0.0:443"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<Input
|
||||
name="server_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
defaultValue={node.server_port}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<Input
|
||||
name="client_name"
|
||||
defaultValue={node.client_name}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<Input
|
||||
name="auth_token"
|
||||
defaultValue={node.auth_token}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<Input
|
||||
name="bandwidth_up_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_up_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<Input
|
||||
name="bandwidth_down_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_down_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<Input name="sni" defaultValue={node.sni ?? ""} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="skip_cert_verify"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||
/>
|
||||
<span>跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<Input
|
||||
name="traffic_stats_listen"
|
||||
defaultValue={node.traffic_stats_listen}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<Input
|
||||
name="traffic_stats_url"
|
||||
defaultValue={node.traffic_stats_url}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<Input
|
||||
name="traffic_stats_secret"
|
||||
defaultValue={node.traffic_stats_secret}
|
||||
placeholder="留空保持当前值"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<Input
|
||||
name="poll_interval_seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
max="30"
|
||||
defaultValue={node.poll_interval_seconds}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.enabled)}
|
||||
/>
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<SubmitButton variant="outline">
|
||||
保存节点
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
|
||||
<details className="config-collapse">
|
||||
<summary>查看 hy2 config.yaml</summary>
|
||||
<pre>{buildNodeHy2Config(node, origin)}</pre>
|
||||
<a
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
href={`/api/admin/nodes/${node.slug}/config`}
|
||||
>
|
||||
下载 hy2 config.yaml
|
||||
</a>
|
||||
</details>
|
||||
<details className="config-collapse">
|
||||
<summary>查看 hy2 config.yaml</summary>
|
||||
<pre>{buildNodeHy2Config(node, origin)}</pre>
|
||||
<a
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
href={`/api/admin/nodes/${node.slug}/config`}
|
||||
>
|
||||
下载 hy2 config.yaml
|
||||
</a>
|
||||
</details>
|
||||
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">
|
||||
{node.last_error_message}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="entity-action-strip">
|
||||
<ActionForm action={deleteNodeAction}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/subscription";
|
||||
import {
|
||||
getAdminMirrorUser,
|
||||
getNodeProxyName,
|
||||
getSubscriptionSettings,
|
||||
listSubscriptionNodes,
|
||||
} from "@/lib/store";
|
||||
@@ -25,11 +26,13 @@ export default async function AdminSubscriptionsPage() {
|
||||
]);
|
||||
const proxyGroupNodes = nodes.map((node) => ({
|
||||
slug: node.slug,
|
||||
label: node.client_name || node.name,
|
||||
label: getNodeProxyName(node),
|
||||
meta:
|
||||
node.client_name && node.client_name !== node.name
|
||||
? `${node.name} / ${node.slug}`
|
||||
: node.slug,
|
||||
node.node_type === "custom"
|
||||
? `${node.name} / ${node.slug} / 自定义`
|
||||
: node.client_name && node.client_name !== node.name
|
||||
? `${node.name} / ${node.slug}`
|
||||
: node.slug,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user