feat: 支持自定义订阅节点并兼容现有数据库

This commit is contained in:
2026-09-29 11:52:41 +08:00
parent 3faf960f35
commit ab7d1c937a
27 changed files with 2203 additions and 449 deletions
+223 -173
View File
@@ -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}>