init
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="nav-list">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`nav-link ${active ? "is-active" : ""}`}
|
||||
>
|
||||
<span className="nav-link__label">{item.label}</span>
|
||||
<span className="nav-link__meta">{item.meta}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { formatBytes, listAudits } from "@/lib/store";
|
||||
|
||||
export default async function AuditsPage() {
|
||||
const audits = await listAudits();
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel hero-panel--compact">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">AUDIT</span>
|
||||
<h2>鉴权审计</h2>
|
||||
<p className="page-lede">记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。</p>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>最近记录</span>
|
||||
<strong>{audits.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="stack-md">
|
||||
{audits.map((audit) => (
|
||||
<div key={audit.id} className="list-row list-row--card">
|
||||
<div>
|
||||
<div className="badge-row">
|
||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||
{audit.ok ? "允许" : "拒绝"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"}
|
||||
</p>
|
||||
<p className="muted mono">{audit.addr ?? "-"}</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p>{audit.reason}</p>
|
||||
<p className="muted">
|
||||
请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}
|
||||
/s
|
||||
</p>
|
||||
<p className="muted">{audit.created_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||
|
||||
function getNodePill(node: {
|
||||
enabled: number;
|
||||
last_error_message: string | null;
|
||||
}) {
|
||||
if (!node.enabled) {
|
||||
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||||
}
|
||||
|
||||
if (node.last_error_message) {
|
||||
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||||
}
|
||||
|
||||
return { label: "运行健康", className: "status-pill status-pill--ok" };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const data = await getDashboardMetrics();
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">OVERVIEW</span>
|
||||
<h2>仪表盘</h2>
|
||||
<p className="page-lede">
|
||||
汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">HTTP 鉴权在线</span>
|
||||
<span className="status-pill status-pill--neutral">轮询聚合已启用</span>
|
||||
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>活动连接</span>
|
||||
<strong>{data.totals.total_online_connections}</strong>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<span>累计流量</span>
|
||||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid">
|
||||
<article className="metric-card metric-card--warm">
|
||||
<span>总用户</span>
|
||||
<strong>{data.totals.total_users}</strong>
|
||||
<small>启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}</small>
|
||||
</article>
|
||||
<article className="metric-card metric-card--ink">
|
||||
<span>活跃用户</span>
|
||||
<strong>{data.totals.active_presence_rows}</strong>
|
||||
<small>在线连接 {data.totals.total_online_connections}</small>
|
||||
</article>
|
||||
<article className="metric-card metric-card--sage">
|
||||
<span>启用节点</span>
|
||||
<strong>{data.totals.enabled_nodes}</strong>
|
||||
<small>轮询与 kick 生效范围</small>
|
||||
</article>
|
||||
<article className="metric-card metric-card--paper">
|
||||
<span>累计流量</span>
|
||||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||
<small>已聚合到 SQLite</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="panel-grid">
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>节点健康</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.nodeHealth.map((node) => (
|
||||
<div key={node.id} className="list-row list-row--card">
|
||||
<div>
|
||||
<strong>{node.name}</strong>
|
||||
<p className="muted mono">/{node.slug}</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p>
|
||||
<span className={getNodePill(node).className}>
|
||||
{getNodePill(node).label}
|
||||
</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||||
</p>
|
||||
<p className="muted">
|
||||
最近成功 {node.last_sync_ok_at ?? "无"}
|
||||
</p>
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>用量排行</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.topUsers.map((user) => (
|
||||
<div key={user.auth_id} className="list-row list-row--card">
|
||||
<div>
|
||||
<strong>{user.username}</strong>
|
||||
<p className="muted mono">{user.auth_id}</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p>{formatBytes(user.total_bytes)}</p>
|
||||
<p className="muted">
|
||||
TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>最近鉴权失败</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.recentFailures.map((item, index) => (
|
||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||
<div>
|
||||
<strong>{item.reason}</strong>
|
||||
<p className="muted">
|
||||
{item.node_name ?? "未知节点"} / {item.username ?? "未知用户"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>最近流量入账</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.recentUsage.map((item, index) => (
|
||||
<div key={`${item.created_at}-${item.auth_id}-${index}`} className="list-row list-row--card">
|
||||
<div>
|
||||
<strong>{item.username ?? item.auth_id}</strong>
|
||||
<p className="muted">{item.node_name ?? "未知节点"}</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p>
|
||||
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
||||
</p>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card full-span">
|
||||
<div className="section-title">
|
||||
<h3>节点同步错误</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.syncErrors.length === 0 ? (
|
||||
<p className="muted">暂无同步错误。</p>
|
||||
) : (
|
||||
data.syncErrors.map((item, index) => (
|
||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||
<div>
|
||||
<strong>{item.node_name}</strong>
|
||||
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
||||
</div>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
|
||||
import { AdminNav } from "./admin-nav";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" },
|
||||
{ href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" },
|
||||
{ href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" },
|
||||
{ href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" },
|
||||
{ href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" },
|
||||
];
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await requireAdminAccess();
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<span className="eyebrow">HY2 PANEL</span>
|
||||
<h1>鹅梯控制台</h1>
|
||||
<p className="muted">
|
||||
管理员 {session.username} 已登录
|
||||
</p>
|
||||
</div>
|
||||
<AdminNav items={navItems} />
|
||||
<form action="/logout" method="post">
|
||||
<button className="ghost-button full-width" type="submit">
|
||||
退出登录
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
<div className="content-shell">
|
||||
<div className="content-frame">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import { createNode, deleteNode, updateNode } from "@/lib/store";
|
||||
|
||||
function boolFromForm(value: FormDataEntryValue | null) {
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
export async function createNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await createNode({
|
||||
slug: String(formData.get("slug") ?? "").trim(),
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
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(),
|
||||
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,
|
||||
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(),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function updateNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await updateNode({
|
||||
id: Number(formData.get("id")),
|
||||
slug: String(formData.get("slug") ?? "").trim(),
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
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(),
|
||||
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,
|
||||
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(),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function deleteNodeAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await deleteNode(Number(formData.get("id")));
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||
|
||||
import { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions";
|
||||
|
||||
function getNodePills(node: {
|
||||
enabled: number;
|
||||
last_error_message: string | null;
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
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" },
|
||||
];
|
||||
}
|
||||
|
||||
export default async function NodesPage() {
|
||||
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">NODES</span>
|
||||
<h2>节点管理</h2>
|
||||
<p className="page-lede">
|
||||
统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--neutral">URL 自动推导</span>
|
||||
<span className="status-pill status-pill--warm">Secret 自动生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>节点总数</span>
|
||||
<strong>{nodes.length}</strong>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<span>启用中</span>
|
||||
<strong>{nodes.filter((node) => node.enabled).length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>新增节点</h3>
|
||||
</div>
|
||||
<form action={createNodeAction} className="form-grid">
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" placeholder="tokyo-01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" placeholder="Tokyo 01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" placeholder="jp1.example.com" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<input name="hy2_listen" defaultValue=":443" placeholder=":443 或 0.0.0.0:443" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<input
|
||||
name="server_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
defaultValue={443}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" placeholder="留空使用默认" />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="skip_cert_verify" type="checkbox" />
|
||||
<span>客户端跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input
|
||||
name="traffic_stats_url"
|
||||
placeholder="留空自动按客户端地址 + 监听端口生成"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<input
|
||||
name="poll_interval_seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
max="30"
|
||||
defaultValue={15}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建节点
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="stack-lg">
|
||||
{nodes.map((node) => {
|
||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||
node.server_host,
|
||||
node.traffic_stats_listen,
|
||||
);
|
||||
|
||||
return (
|
||||
<article key={node.id} className="panel-card entity-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>{node.name}</h3>
|
||||
<p className="muted mono">/{node.slug}</p>
|
||||
</div>
|
||||
<div className="align-right stack-tight">
|
||||
<div className="badge-row badge-row--end">
|
||||
{getNodePills(node).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted">
|
||||
最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "}
|
||||
{node.last_error_at ?? "无"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="metric-inline">
|
||||
<span>hy2 监听</span>
|
||||
<strong className="mono">{node.hy2_listen}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>对外地址</span>
|
||||
<strong className="mono">{node.server_host}:{node.server_port}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>trafficStats</span>
|
||||
<strong className="mono break-all">{node.traffic_stats_url}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={updateNodeAction} className="form-grid compact">
|
||||
<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>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存节点
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="instruction-block">
|
||||
<strong>节点接入说明</strong>
|
||||
<p className="muted">
|
||||
hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。
|
||||
</p>
|
||||
<pre>
|
||||
{buildNodeHy2Config(node, origin)}
|
||||
</pre>
|
||||
<p className="muted">
|
||||
面板轮询地址:<span className="mono">{node.traffic_stats_url}</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
自动推导地址:<span className="mono">{suggestedTrafficStatsUrl}</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
鉴权 URL:<span className="mono">{authUrl}</span>
|
||||
</p>
|
||||
<a
|
||||
className="ghost-button inline-button"
|
||||
href={`/api/admin/nodes/${node.slug}/config`}
|
||||
>
|
||||
下载 hy2 config.yaml
|
||||
</a>
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form action={deleteNodeAction}>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除节点
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { createStableId } from "@/lib/password";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
getSubscriptionSettings,
|
||||
saveProxyGroups,
|
||||
saveSubscriptionSettings,
|
||||
} from "@/lib/store";
|
||||
|
||||
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
await saveSubscriptionSettings({
|
||||
template: String(formData.get("template") ?? ""),
|
||||
extraProxyGroups: String(formData.get("extra_proxy_groups") ?? ""),
|
||||
profileName: String(formData.get("profile_name") ?? "").trim() || "FeieProxy",
|
||||
});
|
||||
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function saveProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
const settings = await getSubscriptionSettings();
|
||||
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const memberSlugs = formData
|
||||
.getAll("member_slugs")
|
||||
.map((value) => String(value))
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id);
|
||||
proxyGroups.push({
|
||||
id,
|
||||
name,
|
||||
type: "select",
|
||||
memberSlugs,
|
||||
});
|
||||
|
||||
await saveProxyGroups(proxyGroups);
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function deleteProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
const settings = await getSubscriptionSettings();
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
await saveProxyGroups(settings.proxyGroups.filter((group) => group.id !== id));
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { buildSubscriptionUrl, getDefaultClashTemplate } from "@/lib/subscription";
|
||||
import {
|
||||
getAdminMirrorUser,
|
||||
getSubscriptionSettings,
|
||||
listSubscriptionNodes,
|
||||
} from "@/lib/store";
|
||||
|
||||
import {
|
||||
deleteProxyGroupAction,
|
||||
saveProxyGroupAction,
|
||||
saveSubscriptionSettingsAction,
|
||||
} from "./actions";
|
||||
|
||||
export default async function AdminSubscriptionsPage() {
|
||||
const [settings, adminUser, nodes, origin] = await Promise.all([
|
||||
getSubscriptionSettings(),
|
||||
getAdminMirrorUser(),
|
||||
listSubscriptionNodes(),
|
||||
getRequestOrigin(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">SUBSCRIPTIONS</span>
|
||||
<h2>订阅与模板</h2>
|
||||
<p className="page-lede">
|
||||
管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">动态 proxies</span>
|
||||
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>可用节点</span>
|
||||
<strong>{nodes.length}</strong>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<span>默认主组</span>
|
||||
<strong>{settings.profileName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>管理员订阅</h3>
|
||||
</div>
|
||||
{adminUser?.subscription_token ? (
|
||||
<div className="instruction-block no-margin">
|
||||
<p className="muted mono break-all">
|
||||
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">管理员订阅链接暂不可用。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Proxy Groups</h3>
|
||||
</div>
|
||||
<div className="stack-lg">
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<article key={group.id} className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<label className="field">
|
||||
<span>组名</span>
|
||||
<input name="name" defaultValue={group.name} required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`${group.id}-${node.slug}`} className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="member_slugs"
|
||||
value={node.slug}
|
||||
defaultChecked={group.memberSlugs.includes(node.slug)}
|
||||
/>
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button className="ghost-button" type="submit">
|
||||
保存分组
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
))}
|
||||
|
||||
<article className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<label className="field">
|
||||
<span>新建分组名</span>
|
||||
<input name="name" placeholder="ChatGPT" required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`new-${node.slug}`} className="checkbox">
|
||||
<input type="checkbox" name="member_slugs" value={node.slug} />
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary-button" type="submit">
|
||||
新建分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Clash 模板</h3>
|
||||
</div>
|
||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||
<label className="field">
|
||||
<span>订阅文件名 / 默认主组名</span>
|
||||
<input name="profile_name" defaultValue={settings.profileName} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>基础模板</span>
|
||||
<textarea
|
||||
name="template"
|
||||
rows={18}
|
||||
defaultValue={settings.template || getDefaultClashTemplate()}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>附加 proxy-groups YAML</span>
|
||||
<textarea
|
||||
name="extra_proxy_groups"
|
||||
rows={12}
|
||||
defaultValue={settings.extraProxyGroups}
|
||||
placeholder={` - name: ChatGPT\n type: select\n proxies: ["US 01 - hy2", "JP 01 - hy2"]`}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
保存订阅模板
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
resetUserPassword,
|
||||
toBytesFromGiB,
|
||||
updateUser,
|
||||
} from "@/lib/store";
|
||||
|
||||
function boolFromForm(value: FormDataEntryValue | null) {
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
function optionalDateTime(value: FormDataEntryValue | null) {
|
||||
const raw = String(value ?? "").trim();
|
||||
return raw ? new Date(raw).toISOString() : null;
|
||||
}
|
||||
|
||||
export async function createUserAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await createUser({
|
||||
username: String(formData.get("username") ?? "").trim(),
|
||||
password: String(formData.get("password") ?? ""),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
isAdmin: false,
|
||||
expiresAt: optionalDateTime(formData.get("expires_at")),
|
||||
trafficLimitBytes: toBytesFromGiB(String(formData.get("traffic_limit_gib") ?? "")),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function updateUserAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await updateUser({
|
||||
id: Number(formData.get("id")),
|
||||
username: String(formData.get("username") ?? "").trim(),
|
||||
enabled: boolFromForm(formData.get("enabled")),
|
||||
subscriptionLabel:
|
||||
String(formData.get("subscription_label") ?? "").trim() ||
|
||||
String(formData.get("username") ?? "").trim(),
|
||||
expiresAt: optionalDateTime(formData.get("expires_at")),
|
||||
trafficLimitBytes: toBytesFromGiB(String(formData.get("traffic_limit_gib") ?? "")),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function deleteUserAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
await deleteUser(Number(formData.get("id")));
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath("/admin/dashboard");
|
||||
}
|
||||
|
||||
export async function resetUserPasswordAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
if (!password) return;
|
||||
|
||||
await resetUserPassword(Number(formData.get("id")), password);
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import {
|
||||
buildSubscriptionUrl,
|
||||
describeRemainingTraffic,
|
||||
} from "@/lib/subscription";
|
||||
import {
|
||||
formatBytes,
|
||||
listUsers,
|
||||
toDatetimeLocalValue,
|
||||
} from "@/lib/store";
|
||||
|
||||
import {
|
||||
createUserAction,
|
||||
deleteUserAction,
|
||||
resetUserPasswordAction,
|
||||
updateUserAction,
|
||||
} from "./actions";
|
||||
|
||||
function getUserPills(user: {
|
||||
enabled: number;
|
||||
is_admin: number;
|
||||
expires_at: string | null;
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
label: user.enabled ? "已启用" : "已禁用",
|
||||
className: `status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`,
|
||||
},
|
||||
user.is_admin
|
||||
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||
: null,
|
||||
user.expires_at
|
||||
? { label: user.expires_at, className: "status-pill status-pill--neutral" }
|
||||
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
||||
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
||||
}
|
||||
|
||||
export default async function UsersPage() {
|
||||
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">USERS</span>
|
||||
<h2>用户管理</h2>
|
||||
<p className="page-lede">
|
||||
管理用户名:密码、`auth_id`、到期时间、额度、订阅名和用户自助链接。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">自助登录</span>
|
||||
<span className="status-pill status-pill--neutral">订阅链接按用户独立生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>用户数</span>
|
||||
<strong>{users.length}</strong>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<span>启用中</span>
|
||||
<strong>{users.filter((user) => user.enabled).length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>新建用户</h3>
|
||||
</div>
|
||||
<form action={createUserAction} className="form-grid">
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input name="expires_at" type="datetime-local" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
||||
</label>
|
||||
<label className="field full-span">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={3} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>立即启用</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建用户
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="stack-lg">
|
||||
{users.map((user) => (
|
||||
<article key={user.id} className="panel-card entity-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>{user.username}</h3>
|
||||
<p className="muted mono">auth_id: {user.auth_id}</p>
|
||||
</div>
|
||||
<div className="align-right stack-tight">
|
||||
<div className="badge-row badge-row--end">
|
||||
{getUserPills(user).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted">
|
||||
累计 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)} / 在线{" "}
|
||||
{user.online_connections}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="metric-inline">
|
||||
<span>总用量</span>
|
||||
<strong>{formatBytes(user.used_tx_bytes + user.used_rx_bytes)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>剩余额度</span>
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>在线连接</span>
|
||||
<strong>{user.online_connections}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateUserAction} className="form-grid compact">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" defaultValue={user.username} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input
|
||||
name="expires_at"
|
||||
type="datetime-local"
|
||||
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input
|
||||
name="traffic_limit_gib"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue={
|
||||
user.traffic_limit_bytes && !user.is_admin
|
||||
? (user.traffic_limit_bytes / 1024 / 1024 / 1024).toFixed(2)
|
||||
: ""
|
||||
}
|
||||
disabled={Boolean(user.is_admin)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>订阅名称</span>
|
||||
<input
|
||||
name="subscription_label"
|
||||
defaultValue={user.subscription_label || user.username}
|
||||
/>
|
||||
</label>
|
||||
<label className="field full-span">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={3} defaultValue={user.notes} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(user.enabled)}
|
||||
/>
|
||||
<span>启用用户</span>
|
||||
</label>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存修改
|
||||
</button>
|
||||
</form>
|
||||
<div className="instruction-block">
|
||||
<strong>订阅链接</strong>
|
||||
<p className="muted mono break-all">
|
||||
{user.subscription_token
|
||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||
: "暂不可用"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<form action={resetUserPasswordAction} className="inline-form">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<input name="password" type="password" placeholder="新密码" required />
|
||||
<button className="ghost-button" type="submit">
|
||||
重置密码
|
||||
</button>
|
||||
</form>
|
||||
{user.is_admin ? null : (
|
||||
<form action={deleteUserAction}>
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除用户
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user