深度优化 ui
This commit is contained in:
+14
-2
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Server,
|
||||
Radio,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -10,18 +17,23 @@ type NavItem = {
|
||||
|
||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
const icons = [LayoutDashboard, Users, Server, Radio, ScrollText];
|
||||
|
||||
return (
|
||||
<nav className="nav-list">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
{items.map((item, index) => {
|
||||
const Icon = icons[index];
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`nav-link ${active ? "is-active" : ""}`}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span className="nav-link__label">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
+60
-36
@@ -1,3 +1,4 @@
|
||||
import { FilterList } from "@/components/filter-list";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { formatBytes, listAudits } from "@/lib/store";
|
||||
|
||||
@@ -10,47 +11,70 @@ export default async function AuditsPage() {
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">审计</span>
|
||||
<h2>鉴权审计</h2>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
<span>最近记录</span>
|
||||
<strong>{audits.length}</strong>
|
||||
</div>
|
||||
<span className="status-pill">最近 {audits.length} 条</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="entity-table entity-table--audits">
|
||||
<div className="entity-table__head audit-row-grid">
|
||||
<span>结果</span>
|
||||
<span>节点 / 用户</span>
|
||||
<span>来源</span>
|
||||
<span>请求速率</span>
|
||||
<span>原因</span>
|
||||
<span>时间</span>
|
||||
</div>
|
||||
{audits.map((audit) => (
|
||||
<div key={audit.id} className="entity-table__summary audit-row-grid">
|
||||
<span>
|
||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||
{audit.ok ? "允许" : "拒绝"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-main-cell">
|
||||
<strong>{audit.node_name ?? "未知节点"}</strong>
|
||||
<span className="muted mono break-all">{audit.username ?? audit.auth_id ?? "-"}</span>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">{audit.addr ?? "-"}</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}</strong>
|
||||
<span>/s</span>
|
||||
</span>
|
||||
<span>{audit.reason}</span>
|
||||
<span className="entity-muted-cell">{formatLocalDateTime(audit.created_at)}</span>
|
||||
<FilterList
|
||||
className="entity-table entity-table--audits"
|
||||
placeholder="搜索节点、用户、来源、原因…"
|
||||
statuses={[
|
||||
{ value: "ok", label: "允许" },
|
||||
{ value: "denied", label: "拒绝" },
|
||||
]}
|
||||
header={
|
||||
<div className="entity-table__head audit-row-grid">
|
||||
<span>结果</span>
|
||||
<span>节点 / 用户</span>
|
||||
<span>来源</span>
|
||||
<span>请求速率</span>
|
||||
<span>原因</span>
|
||||
<span>时间</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
items={audits.map((audit) => ({
|
||||
id: audit.id,
|
||||
text: `${audit.node_name ?? ""} ${audit.username ?? ""} ${audit.auth_id ?? ""} ${audit.addr ?? ""} ${audit.reason}`,
|
||||
status: audit.ok ? "ok" : "denied",
|
||||
content: (
|
||||
<div
|
||||
key={audit.id}
|
||||
className="entity-table__summary audit-row-grid"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}
|
||||
>
|
||||
{audit.ok ? "允许" : "拒绝"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-main-cell">
|
||||
<strong>{audit.node_name ?? "未知节点"}</strong>
|
||||
<span className="muted mono break-all">
|
||||
{audit.username ?? audit.auth_id ?? "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">
|
||||
{audit.addr ?? "-"}
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>
|
||||
{audit.requested_tx != null
|
||||
? formatBytes(audit.requested_tx)
|
||||
: "-"}
|
||||
</strong>
|
||||
<span>/s</span>
|
||||
</span>
|
||||
<span>{audit.reason}</span>
|
||||
<span className="entity-muted-cell">
|
||||
{formatLocalDateTime(audit.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||
|
||||
@@ -28,18 +30,12 @@ export default async function DashboardPage() {
|
||||
<span className="eyebrow">概览</span>
|
||||
<h2>仪表盘</h2>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">在线 {data.totals.total_online_connections}</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>
|
||||
<span className="status-pill status-pill--ok">
|
||||
在线 {data.totals.total_online_connections}
|
||||
</span>
|
||||
<span className="status-pill status-pill--warm">
|
||||
节点 {data.totals.enabled_nodes}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -48,7 +44,9 @@ export default async function DashboardPage() {
|
||||
<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>
|
||||
<small>
|
||||
启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}
|
||||
</small>
|
||||
</article>
|
||||
<article className="metric-card metric-card--ink">
|
||||
<span>活跃用户</span>
|
||||
@@ -81,8 +79,17 @@ export default async function DashboardPage() {
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>节点健康</h3>
|
||||
<Link
|
||||
href="/admin/nodes"
|
||||
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||
>
|
||||
管理节点 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.nodeHealth.length === 0 && (
|
||||
<p className="muted">暂无节点,请先添加节点。</p>
|
||||
)}
|
||||
{data.nodeHealth.map((node) => (
|
||||
<div key={node.id} className="list-row list-row--card">
|
||||
<div>
|
||||
@@ -113,8 +120,17 @@ export default async function DashboardPage() {
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>用量排行</h3>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||
>
|
||||
管理用户 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.topUsers.length === 0 && (
|
||||
<p className="muted">暂无用户用量。</p>
|
||||
)}
|
||||
{data.topUsers.map((user) => (
|
||||
<div key={user.auth_id} className="list-row list-row--card">
|
||||
<div>
|
||||
@@ -124,7 +140,8 @@ export default async function DashboardPage() {
|
||||
<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)}
|
||||
TX {formatBytes(user.used_tx_bytes)} / RX{" "}
|
||||
{formatBytes(user.used_rx_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,19 +152,34 @@ export default async function DashboardPage() {
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>最近鉴权失败</h3>
|
||||
<Link
|
||||
href="/admin/audits"
|
||||
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||
>
|
||||
查看日志 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.recentFailures.length === 0 && (
|
||||
<p className="muted">暂无鉴权失败。</p>
|
||||
)}
|
||||
{data.recentFailures.map((item, index) => (
|
||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||
<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 ?? "未知用户"}
|
||||
{item.node_name ?? "未知节点"} /{" "}
|
||||
{item.username ?? "未知用户"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
<p className="muted">
|
||||
{formatLocalDateTime(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -159,17 +191,23 @@ export default async function DashboardPage() {
|
||||
<h3>最近流量入账</h3>
|
||||
</div>
|
||||
<div className="stack-md">
|
||||
{data.recentUsage.length === 0 && (
|
||||
<p className="muted">暂无流量入账记录。</p>
|
||||
)}
|
||||
{data.recentUsage.map((item, index) => (
|
||||
<div key={`${item.created_at}-${item.auth_id}-${index}`} className="list-row list-row--card">
|
||||
<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>+{formatBytes(item.tx_bytes + item.rx_bytes)}</p>
|
||||
<p className="muted">
|
||||
{formatLocalDateTime(item.created_at)}
|
||||
</p>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -185,12 +223,19 @@ export default async function DashboardPage() {
|
||||
<p className="muted">暂无同步错误。</p>
|
||||
) : (
|
||||
data.syncErrors.map((item, index) => (
|
||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||||
<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>
|
||||
<p className="error-text">
|
||||
{item.error_message ?? "unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
<p className="muted">
|
||||
{formatLocalDateTime(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
|
||||
import { AdminNav } from "./admin-nav";
|
||||
@@ -24,14 +25,14 @@ export default async function AdminLayout({
|
||||
<span className="eyebrow">HY2 PANEL</span>
|
||||
<h1>鹅梯控制台</h1>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ink">{session.username}</span>
|
||||
<span className="status-pill status-pill--ink">
|
||||
{session.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminNav items={navItems} />
|
||||
<form action="/logout" method="post">
|
||||
<button className="ghost-button full-width" type="submit">
|
||||
退出登录
|
||||
</button>
|
||||
<SubmitButton variant="outline">退出登录</SubmitButton>
|
||||
</form>
|
||||
</aside>
|
||||
<div className="content-shell">
|
||||
|
||||
+325
-183
@@ -1,9 +1,19 @@
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FilterList } from "@/components/filter-list";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions";
|
||||
import {
|
||||
createNodeAction,
|
||||
deleteNodeAction,
|
||||
updateNodeAction,
|
||||
} from "./actions";
|
||||
|
||||
function getNodePills(node: {
|
||||
enabled: number;
|
||||
@@ -30,18 +40,12 @@ export default async function NodesPage() {
|
||||
<span className="eyebrow">节点</span>
|
||||
<h2>节点管理</h2>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">启用 {nodes.filter((node) => node.enabled).length}</span>
|
||||
<span className="status-pill status-pill--neutral">总数 {nodes.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>{nodes.filter((node) => node.enabled).length}</strong>
|
||||
<span className="status-pill status-pill--ok">
|
||||
启用 {nodes.filter((node) => node.enabled).length}
|
||||
</span>
|
||||
<span className="status-pill status-pill--neutral">
|
||||
总数 {nodes.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -54,46 +58,76 @@ export default async function NodesPage() {
|
||||
</div>
|
||||
<span className="status-pill status-pill--warm">展开</span>
|
||||
</summary>
|
||||
<form action={createNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
||||
<ActionForm
|
||||
action={createNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" placeholder="tokyo-01" required />
|
||||
<Input name="slug" placeholder="tokyo-01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" placeholder="Tokyo 01" required />
|
||||
<Input name="name" placeholder="Tokyo 01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" placeholder="jp1.example.com" required />
|
||||
<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" />
|
||||
<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 />
|
||||
<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" />
|
||||
<Input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" placeholder="留空自动生成" />
|
||||
<Input name="auth_token" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
||||
<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 />
|
||||
<Input
|
||||
name="bandwidth_down_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={30}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" placeholder="留空使用默认" />
|
||||
<Input name="sni" placeholder="留空使用默认" />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="skip_cert_verify" type="checkbox" />
|
||||
@@ -101,196 +135,304 @@ export default async function NodesPage() {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
||||
<Input
|
||||
name="traffic_stats_listen"
|
||||
defaultValue=":9999"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input name="traffic_stats_url" placeholder="留空自动推导" />
|
||||
<Input name="traffic_stats_url" placeholder="留空自动推导" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||
<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 />
|
||||
<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>
|
||||
<SubmitButton variant="default">创建节点</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>节点列表</h3>
|
||||
<span className="status-pill status-pill--neutral">{nodes.length} 个节点</span>
|
||||
<span className="status-pill status-pill--neutral">
|
||||
{nodes.length} 个节点
|
||||
</span>
|
||||
</div>
|
||||
<div className="entity-table entity-table--nodes">
|
||||
<div className="entity-table__head node-row-grid">
|
||||
<span>节点</span>
|
||||
<span>状态</span>
|
||||
<span>对外地址</span>
|
||||
<span>trafficStats</span>
|
||||
<span>在线/流</span>
|
||||
<span>最近同步</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{nodes.map((node) => {
|
||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||
<FilterList
|
||||
className="entity-table entity-table--nodes"
|
||||
placeholder="搜索节点、地址、Slug…"
|
||||
statuses={[
|
||||
{ value: "enabled", label: "已启用" },
|
||||
{ value: "disabled", label: "已禁用" },
|
||||
]}
|
||||
header={
|
||||
<div className="entity-table__head node-row-grid">
|
||||
<span>节点</span>
|
||||
<span>状态</span>
|
||||
<span>对外地址</span>
|
||||
<span>trafficStats</span>
|
||||
<span>在线/流</span>
|
||||
<span>最近同步</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
}
|
||||
items={nodes.map((node) => {
|
||||
const authUrl = buildNodeAuthUrl(
|
||||
node.slug,
|
||||
node.auth_token,
|
||||
origin,
|
||||
);
|
||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||
node.server_host,
|
||||
node.traffic_stats_listen,
|
||||
);
|
||||
|
||||
return (
|
||||
<details key={node.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary node-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{node.name}</strong>
|
||||
<span className="muted mono">/{node.slug}</span>
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getNodePills(node).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-code-cell">
|
||||
{node.server_host}:{node.server_port}
|
||||
<small>hy2 {node.hy2_listen}</small>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">
|
||||
{node.traffic_stats_url}
|
||||
<small>{node.traffic_stats_listen}</small>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{node.last_online_users} / {node.last_stream_count}</strong>
|
||||
<span>用户 / 流</span>
|
||||
</span>
|
||||
<span className="entity-muted-cell">
|
||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{node.last_error_at ? <small>错误 {formatLocalDateTime(node.last_error_at)}</small> : null}
|
||||
</span>
|
||||
<span className="entity-expand-label">编辑</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-grid">
|
||||
<div className="dense-code-line">
|
||||
<span>鉴权 URL</span>
|
||||
<code>{authUrl}</code>
|
||||
return {
|
||||
id: node.id,
|
||||
text: `${node.name} ${node.slug} ${node.server_host} ${node.client_name}`,
|
||||
status: node.enabled ? "enabled" : "disabled",
|
||||
content: (
|
||||
<details key={node.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary node-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{node.name}</strong>
|
||||
<span className="muted mono">/{node.slug}</span>
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getNodePills(node).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-code-cell">
|
||||
{node.server_host}:{node.server_port}
|
||||
<small>hy2 {node.hy2_listen}</small>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">
|
||||
{node.traffic_stats_url}
|
||||
<small>{node.traffic_stats_listen}</small>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>
|
||||
{node.last_online_users} / {node.last_stream_count}
|
||||
</strong>
|
||||
<span>用户 / 流</span>
|
||||
</span>
|
||||
<span className="entity-muted-cell">
|
||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{node.last_error_at ? (
|
||||
<small>
|
||||
错误 {formatLocalDateTime(node.last_error_at)}
|
||||
</small>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="entity-expand-label">
|
||||
<span className="when-closed">编辑</span>
|
||||
<span className="when-open">收起</span>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dense-code-line">
|
||||
<span>自动推导 trafficStats</span>
|
||||
<code>{suggestedTrafficStatsUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dense-code-line">
|
||||
<span>自动推导 trafficStats</span>
|
||||
<code>{suggestedTrafficStatsUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form 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>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存节点
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<details className="config-collapse">
|
||||
<summary>查看 hy2 config.yaml</summary>
|
||||
<pre>
|
||||
{buildNodeHy2Config(node, origin)}
|
||||
</pre>
|
||||
<a className="ghost-button inline-button" 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}
|
||||
|
||||
<div className="entity-action-strip">
|
||||
<form action={deleteNodeAction}>
|
||||
<ActionForm
|
||||
action={updateNodeAction}
|
||||
className="dense-form-grid dense-form-grid--nodes"
|
||||
>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除节点
|
||||
</button>
|
||||
</form>
|
||||
<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>
|
||||
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="entity-action-strip">
|
||||
<ActionForm action={deleteNodeAction}>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm={`将永久删除节点「${node.name}」及其面板配置。`}
|
||||
>
|
||||
删除节点
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
</details>
|
||||
),
|
||||
};
|
||||
})}
|
||||
</div>
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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 { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { buildSubscriptionUrl, getDefaultClashTemplate } from "@/lib/subscription";
|
||||
import {
|
||||
buildSubscriptionUrl,
|
||||
getDefaultClashTemplate,
|
||||
} from "@/lib/subscription";
|
||||
import {
|
||||
getAdminMirrorUser,
|
||||
getSubscriptionSettings,
|
||||
@@ -32,18 +39,12 @@ export default async function AdminSubscriptionsPage() {
|
||||
<span className="eyebrow">订阅</span>
|
||||
<h2>订阅与模板</h2>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">节点 {nodes.length}</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>
|
||||
<span className="status-pill status-pill--ok">
|
||||
节点 {nodes.length}
|
||||
</span>
|
||||
<span className="status-pill status-pill--ink">
|
||||
分组 {settings.proxyGroups.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -54,9 +55,17 @@ export default async function AdminSubscriptionsPage() {
|
||||
</div>
|
||||
{adminUser?.subscription_token ? (
|
||||
<div className="instruction-block no-margin">
|
||||
<p className="muted mono break-all">
|
||||
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
||||
</p>
|
||||
<div className="copy-line">
|
||||
<code>
|
||||
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={buildSubscriptionUrl(
|
||||
adminUser.subscription_token,
|
||||
origin,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">管理员订阅链接暂不可用。</p>
|
||||
@@ -81,21 +90,43 @@ export default async function AdminSubscriptionsPage() {
|
||||
/>
|
||||
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||
<details key={group.id} className="group-disclosure">
|
||||
<summary className="group-summary">
|
||||
<strong>{group.name}</strong>
|
||||
<span>{group.memberSlugs.length} 个节点 · 编辑分组</span>
|
||||
</summary>
|
||||
<ProxyGroupEditor group={group} nodes={proxyGroupNodes} />
|
||||
</details>
|
||||
))}
|
||||
|
||||
<ProxyGroupEditor key={`new-${settings.proxyGroups.length}`} nodes={proxyGroupNodes} />
|
||||
<details
|
||||
key={`new-${settings.proxyGroups.length}`}
|
||||
className="group-disclosure"
|
||||
>
|
||||
<summary className="group-summary">
|
||||
<strong>新建分组</strong>
|
||||
</summary>
|
||||
<ProxyGroupEditor nodes={proxyGroupNodes} />
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>订阅模板</h3>
|
||||
</div>
|
||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||
<details className="panel-card group-disclosure">
|
||||
<summary className="group-summary">
|
||||
<strong>订阅模板</strong>
|
||||
<span>编辑 YAML 与默认组名</span>
|
||||
</summary>
|
||||
<ActionForm
|
||||
action={saveSubscriptionSettingsAction}
|
||||
className="stack-lg"
|
||||
>
|
||||
<label className="field">
|
||||
<span>订阅文件名 / 默认主组名</span>
|
||||
<input name="profile_name" defaultValue={settings.profileName} required />
|
||||
<Input
|
||||
name="profile_name"
|
||||
defaultValue={settings.profileName}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>基础模板</span>
|
||||
@@ -114,11 +145,9 @@ export default async function AdminSubscriptionsPage() {
|
||||
placeholder={` - name: ChatGPT\n type: select\n proxies: ["US 01 - hy2", "JP 01 - hy2"]`}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
保存订阅模板
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<SubmitButton variant="default">保存订阅模板</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { useDeferredValue, useState, useId, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import {
|
||||
@@ -55,33 +76,93 @@ function normalizeSelectedSlugs(
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
||||
function EditorFields({ children }: { children: ReactNode }) {
|
||||
const { pending } = useFormStatus();
|
||||
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
||||
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
<fieldset disabled={pending} className="stack-md min-w-0 border-0 p-0 m-0">
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableNode({
|
||||
node,
|
||||
index,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
node: ProxyGroupNodeOption;
|
||||
index: number;
|
||||
removable: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: node.slug });
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
className="proxy-sort-item"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
position: "relative",
|
||||
zIndex: isDragging ? 2 : undefined,
|
||||
background: isDragging ? "#f4f4f5" : undefined,
|
||||
}}
|
||||
>
|
||||
{pending ? pendingLabel : label}
|
||||
</button>
|
||||
<Button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`拖拽排序 ${node.label}`}
|
||||
title="拖拽排序;键盘按空格开始,方向键移动,空格放下"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="drag-handle"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</Button>
|
||||
<span className="proxy-sort-item__index">{index + 1}</span>
|
||||
<div className="stack-tight min-w-0 flex-1">
|
||||
<strong>{node.label}</strong>
|
||||
<span className="proxy-sort-item__sub">{node.meta}</span>
|
||||
</div>
|
||||
{removable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label={`移除 ${node.label}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button className="danger-button" type="submit" disabled={pending}>
|
||||
{pending ? "删除中..." : "删除分组"}
|
||||
</button>
|
||||
export function ProxyGroupEditor({
|
||||
mode = "custom",
|
||||
group,
|
||||
nodes,
|
||||
}: ProxyGroupEditorProps) {
|
||||
const dndId = useId();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
||||
const isMainGroup = mode === "main";
|
||||
const isEditing = Boolean(group) && !isMainGroup;
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -89,6 +170,15 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
);
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [saved, setSaved] = useState(() =>
|
||||
JSON.stringify([
|
||||
group?.name ?? "",
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
]),
|
||||
);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const dirty = saved !== JSON.stringify([name, selectedSlugs]);
|
||||
const selectedSlugSet = new Set(selectedSlugs);
|
||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||
const selectedNodes = selectedSlugs
|
||||
@@ -111,6 +201,7 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
});
|
||||
|
||||
function addNode(slug: string) {
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) => {
|
||||
if (current.includes(slug)) {
|
||||
return current;
|
||||
@@ -121,25 +212,14 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
}
|
||||
|
||||
function removeNode(slug: string) {
|
||||
setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug));
|
||||
}
|
||||
|
||||
function moveNode(slug: string, direction: -1 | 1) {
|
||||
setSelectedSlugs((current) => {
|
||||
const index = current.indexOf(slug);
|
||||
const nextIndex = index + direction;
|
||||
|
||||
if (index === -1 || nextIndex < 0 || nextIndex >= current.length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = [...current];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) =>
|
||||
current.filter((currentSlug) => currentSlug !== slug),
|
||||
);
|
||||
}
|
||||
|
||||
function addAllVisibleNodes() {
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) => {
|
||||
const next = [...current];
|
||||
const seen = new Set(current);
|
||||
@@ -158,170 +238,255 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setFeedback("");
|
||||
setSelectedSlugs([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="instruction-block proxy-group-card">
|
||||
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
|
||||
{group && !isMainGroup ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h4 className="proxy-group-title">
|
||||
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
|
||||
</h4>
|
||||
<form
|
||||
action={async (formData) => {
|
||||
setFeedback("");
|
||||
try {
|
||||
await (
|
||||
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
|
||||
)(formData);
|
||||
setSaved(JSON.stringify([name, selectedSlugs]));
|
||||
setFeedback("已保存");
|
||||
} catch {
|
||||
setFeedback("保存失败,请重试。当前修改已保留。");
|
||||
}
|
||||
}}
|
||||
className="stack-md"
|
||||
>
|
||||
<EditorFields>
|
||||
{group && !isMainGroup ? (
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
) : null}
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h4 className="proxy-group-title">
|
||||
{isMainGroup
|
||||
? `默认主分组:${group?.name ?? "FeieProxy"}`
|
||||
: isEditing
|
||||
? group?.name
|
||||
: "新建分组"}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="badge-row badge-row--end">
|
||||
<span className="status-pill status-pill--ink">
|
||||
已选 {selectedNodes.length}
|
||||
</span>
|
||||
{!isMainGroup ? (
|
||||
<span className="status-pill status-pill--warm">
|
||||
未加入 {remainingCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="badge-row badge-row--end">
|
||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
||||
{!isMainGroup ? (
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMainGroup ? (
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
|
||||
{!isMainGroup ? (
|
||||
<section className="proxy-group-panel">
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<Input
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setFeedback("");
|
||||
}}
|
||||
placeholder="ChatGPT"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}
|
||||
>
|
||||
{!isMainGroup ? (
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
aria-label="搜索候选节点"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||
<span className="muted text-xs">拖动手柄排序 · 支持键盘</span>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
{!isMainGroup ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
</div>
|
||||
清空已选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<button
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedNodes.length > 0 ? (
|
||||
<DndContext
|
||||
id={dndId}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
accessibility={{
|
||||
screenReaderInstructions: {
|
||||
draggable:
|
||||
"按空格开始拖拽,使用方向键调整顺序,再按空格放下,Escape 取消。",
|
||||
},
|
||||
announcements: {
|
||||
onDragStart: ({ active }) =>
|
||||
`开始移动 ${nodesBySlug.get(String(active.id))?.label ?? active.id}`,
|
||||
onDragOver: ({ over }) =>
|
||||
over
|
||||
? `目标位置 ${selectedSlugs.indexOf(String(over.id)) + 1}`
|
||||
: "已离开列表",
|
||||
onDragEnd: ({ over }) =>
|
||||
over
|
||||
? `已放到第 ${selectedSlugs.indexOf(String(over.id)) + 1} 位,请保存顺序`
|
||||
: "顺序未变",
|
||||
onDragCancel: () => "已取消排序",
|
||||
},
|
||||
}}
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (over && active.id !== over.id) {
|
||||
setSelectedSlugs((current) =>
|
||||
arrayMove(
|
||||
current,
|
||||
current.indexOf(String(active.id)),
|
||||
current.indexOf(String(over.id)),
|
||||
),
|
||||
);
|
||||
setFeedback("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedSlugs}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ol className="proxy-sort-list">
|
||||
{selectedNodes.map((node, index) => (
|
||||
<SortableNode
|
||||
key={node.slug}
|
||||
node={node}
|
||||
index={index}
|
||||
removable={!isMainGroup}
|
||||
onRemove={() => removeNode(node.slug)}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
{isMainGroup
|
||||
? "暂无可用节点。"
|
||||
: "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||
</div>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedNodes.length > 0 ? (
|
||||
<ol className="proxy-sort-list">
|
||||
{selectedNodes.map((node, index) => (
|
||||
<li key={node.slug} className="proxy-sort-item">
|
||||
<div className="proxy-sort-item__meta">
|
||||
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
|
||||
<div className="stack-tight">
|
||||
<strong>{node.label}</strong>
|
||||
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="proxy-sort-item__actions">
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, 1)}
|
||||
disabled={index === selectedNodes.length - 1}
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||
</p>
|
||||
<div className="action-row action-row--end">
|
||||
<span role="status" className="muted">
|
||||
{feedback || (dirty ? "有未保存的修改" : "")}
|
||||
</span>
|
||||
{dirty && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const [oldName, oldSlugs] = JSON.parse(saved) as [
|
||||
string,
|
||||
string[],
|
||||
];
|
||||
setName(oldName);
|
||||
setSelectedSlugs(oldSlugs);
|
||||
setFeedback("");
|
||||
}}
|
||||
>
|
||||
撤销修改
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row action-row--end">
|
||||
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
||||
</div>
|
||||
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
|
||||
{isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</EditorFields>
|
||||
</form>
|
||||
|
||||
{group && !isMainGroup ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<ActionForm action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm={`将删除分组「${group.name}」,节点本身不受影响。`}
|
||||
>
|
||||
删除分组
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
|
||||
+205
-142
@@ -1,14 +1,15 @@
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FilterList } from "@/components/filter-list";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import {
|
||||
buildSubscriptionUrl,
|
||||
describeRemainingTraffic,
|
||||
} from "@/lib/subscription";
|
||||
import {
|
||||
formatBytes,
|
||||
listUsers,
|
||||
toDatetimeLocalValue,
|
||||
} from "@/lib/store";
|
||||
import { formatBytes, listUsers, toDatetimeLocalValue } from "@/lib/store";
|
||||
|
||||
import {
|
||||
createUserAction,
|
||||
@@ -31,9 +32,14 @@ function getUserPills(user: {
|
||||
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||
: null,
|
||||
user.expires_at
|
||||
? { label: formatLocalDateTime(user.expires_at), className: "status-pill status-pill--neutral" }
|
||||
? {
|
||||
label: formatLocalDateTime(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));
|
||||
].filter((pill): pill is { label: string; className: string } =>
|
||||
Boolean(pill),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function UsersPage() {
|
||||
@@ -46,18 +52,12 @@ export default async function UsersPage() {
|
||||
<span className="eyebrow">用户</span>
|
||||
<h2>用户管理</h2>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">启用 {users.filter((user) => user.enabled).length}</span>
|
||||
<span className="status-pill status-pill--neutral">总数 {users.length}</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>
|
||||
<span className="status-pill status-pill--ok">
|
||||
启用 {users.filter((user) => user.enabled).length}
|
||||
</span>
|
||||
<span className="status-pill status-pill--neutral">
|
||||
总数 {users.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -70,22 +70,30 @@ export default async function UsersPage() {
|
||||
</div>
|
||||
<span className="status-pill status-pill--warm">展开</span>
|
||||
</summary>
|
||||
<form action={createUserAction} className="dense-form-grid dense-form-grid--users">
|
||||
<ActionForm
|
||||
action={createUserAction}
|
||||
className="dense-form-grid dense-form-grid--users"
|
||||
>
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" required />
|
||||
<Input name="username" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" required />
|
||||
<Input name="password" type="password" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input name="expires_at" type="datetime-local" />
|
||||
<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" />
|
||||
<Input
|
||||
name="traffic_limit_gib"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</label>
|
||||
<label className="field dense-field-wide">
|
||||
<span>备注</span>
|
||||
@@ -95,141 +103,196 @@ export default async function UsersPage() {
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>立即启用</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建用户
|
||||
</button>
|
||||
</form>
|
||||
<SubmitButton variant="default">创建用户</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>用户列表</h3>
|
||||
<span className="status-pill status-pill--neutral">{users.length} 个账户</span>
|
||||
<span className="status-pill status-pill--neutral">
|
||||
{users.length} 个账户
|
||||
</span>
|
||||
</div>
|
||||
<div className="entity-table entity-table--users">
|
||||
<div className="entity-table__head user-row-grid">
|
||||
<span>用户</span>
|
||||
<span>状态</span>
|
||||
<span>用量</span>
|
||||
<span>剩余额度</span>
|
||||
<span>在线</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{users.map((user) => {
|
||||
<FilterList
|
||||
className="entity-table entity-table--users"
|
||||
placeholder="搜索用户名、ID、备注…"
|
||||
statuses={[
|
||||
{ value: "enabled", label: "已启用" },
|
||||
{ value: "disabled", label: "已禁用" },
|
||||
]}
|
||||
header={
|
||||
<div className="entity-table__head user-row-grid">
|
||||
<span>用户</span>
|
||||
<span>状态</span>
|
||||
<span>用量</span>
|
||||
<span>剩余额度</span>
|
||||
<span>在线</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
}
|
||||
items={users.map((user) => {
|
||||
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
||||
const subscriptionUrl = user.subscription_token
|
||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||
: "暂不可用";
|
||||
|
||||
return (
|
||||
<details key={user.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary user-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{user.username}</strong>
|
||||
<span className="muted mono break-all">{user.auth_id}</span>
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getUserPills(user).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
return {
|
||||
id: user.id,
|
||||
text: `${user.username} ${user.auth_id} ${user.notes}`,
|
||||
status: user.enabled ? "enabled" : "disabled",
|
||||
content: (
|
||||
<details key={user.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary user-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{user.username}</strong>
|
||||
<span className="muted mono break-all">
|
||||
{user.auth_id}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{formatBytes(usedBytes)}</strong>
|
||||
<span>TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
<span>{user.traffic_limit_bytes ? "有额度" : "不限量"}</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{user.online_connections}</strong>
|
||||
<span>连接</span>
|
||||
</span>
|
||||
<span className="entity-expand-label">编辑</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-line">
|
||||
<span>订阅链接</span>
|
||||
<code>{subscriptionUrl}</code>
|
||||
</div>
|
||||
<form action={updateUserAction} className="dense-form-grid dense-form-grid--users">
|
||||
<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 dense-field-wide">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={2} 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="entity-action-strip">
|
||||
<form action={resetUserPasswordAction} className="inline-form inline-form--compact">
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getUserPills(user).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{formatBytes(usedBytes)}</strong>
|
||||
<span>
|
||||
TX {formatBytes(user.used_tx_bytes)} / RX{" "}
|
||||
{formatBytes(user.used_rx_bytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
<span>
|
||||
{user.traffic_limit_bytes ? "有额度" : "不限量"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{user.online_connections}</strong>
|
||||
<span>连接</span>
|
||||
</span>
|
||||
<span className="entity-expand-label">
|
||||
<span className="when-closed">编辑</span>
|
||||
<span className="when-open">收起</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-line">
|
||||
<span>订阅链接</span>
|
||||
<div className="copy-line">
|
||||
<code>{subscriptionUrl}</code>
|
||||
{user.subscription_token && (
|
||||
<CopyButton value={subscriptionUrl} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ActionForm
|
||||
action={updateUserAction}
|
||||
className="dense-form-grid dense-form-grid--users"
|
||||
>
|
||||
<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}>
|
||||
<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 dense-field-wide">
|
||||
<span>备注</span>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows={2}
|
||||
defaultValue={user.notes}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(user.enabled)}
|
||||
/>
|
||||
<span>启用用户</span>
|
||||
</label>
|
||||
<SubmitButton variant="outline">保存修改</SubmitButton>
|
||||
</ActionForm>
|
||||
<div className="entity-action-strip">
|
||||
<ActionForm
|
||||
action={resetUserPasswordAction}
|
||||
className="inline-form inline-form--compact"
|
||||
>
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除用户
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
aria-label="新密码"
|
||||
autoComplete="new-password"
|
||||
placeholder="新密码"
|
||||
required
|
||||
/>
|
||||
<SubmitButton variant="outline">重置密码</SubmitButton>
|
||||
</ActionForm>
|
||||
{user.is_admin ? null : (
|
||||
<ActionForm action={deleteUserAction}>
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm={`将永久删除用户「${user.username}」,此用户将无法继续连接。`}
|
||||
>
|
||||
删除用户
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
</details>
|
||||
),
|
||||
};
|
||||
})}
|
||||
</div>
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user