深度优化 ui

This commit is contained in:
2026-09-29 11:34:18 +08:00
parent 916a4f5ffc
commit 3faf960f35
27 changed files with 6510 additions and 2232 deletions
+14 -2
View File
@@ -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
View File
@@ -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>
);
}
}
+69 -24
View File
@@ -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>
))
)}
+5 -4
View File
@@ -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
View File
@@ -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>
);
}
}
+58 -29
View File
@@ -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>
);
}
+340 -175
View File
@@ -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
View File
@@ -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>
);
}
}
+889 -1526
View File
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -1,3 +1,5 @@
import { Input } from "@/components/ui/input";
import { SubmitButton } from "@/components/submit-button";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
@@ -41,18 +43,28 @@ export default async function LoginPage({
<form action={loginAction} className="stack-lg login-form">
<label className="field">
<span>用户名</span>
<input name="username" type="text" placeholder="admin" required />
<Input
name="username"
autoComplete="username"
autoFocus
type="text"
placeholder="admin"
required
/>
</label>
<label className="field">
<span>密码</span>
<input name="password" type="password" required />
<Input
name="password"
autoComplete="current-password"
type="password"
required
/>
</label>
{params.error ? (
<p className="error-banner">用户名或密码错误。</p>
) : null}
<button className="primary-button" type="submit">
登录
</button>
<SubmitButton variant="default">登录</SubmitButton>
</form>
</article>
</section>
+23 -11
View File
@@ -1,9 +1,15 @@
import { CopyButton } from "@/components/copy-button";
import { buttonVariants } from "@/components/ui/button";
import { SubmitButton } from "@/components/submit-button";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { formatLocalDateTime } from "@/lib/datetime";
import { getRequestOrigin } from "@/lib/request-origin";
import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscription";
import {
buildSubscriptionUrl,
describeRemainingTraffic,
} from "@/lib/subscription";
import { requireUserSession } from "@/lib/session";
import { formatBytes, getUserById } from "@/lib/store";
@@ -66,22 +72,28 @@ export default async function MePage() {
<article className="me-panel me-subscription-card">
<div className="section-title section-title--top">
<div>
<span className="feature-kicker">订阅</span>
<h2>订阅</h2>
</div>
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
<span className="status-pill status-pill--ink">
Clash / Mihomo
</span>
</div>
<div className="me-action-row">
{clashImportUrl ? (
<a className="primary-button me-action-button" href={clashImportUrl}>
<a
className={buttonVariants({ size: "sm" })}
href={clashImportUrl}
>
一键导入
</a>
) : (
<span className="status-pill status-pill--off">订阅暂不可用</span>
<span className="status-pill status-pill--off">
订阅暂不可用
</span>
)}
{subscriptionUrl ? (
<a
className="ghost-button me-action-button"
className={buttonVariants({ variant: "outline", size: "sm" })}
href={subscriptionUrl}
target="_blank"
rel="noreferrer"
@@ -92,7 +104,10 @@ export default async function MePage() {
</div>
<div className="me-link-card">
<span className="feature-kicker">订阅链接</span>
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
<div className="copy-line">
<code>{subscriptionUrl ?? "暂不可用"}</code>
{subscriptionUrl && <CopyButton value={subscriptionUrl} />}
</div>
</div>
</article>
@@ -122,12 +137,9 @@ export default async function MePage() {
</article>
<article className="me-panel me-support-card">
<span className="feature-kicker">账户</span>
<h2>账户</h2>
<form action="/logout" method="post">
<button className="ghost-button me-action-button me-action-button--full" type="submit">
退出登录
</button>
<SubmitButton variant="outline">退出登录</SubmitButton>
</form>
</article>
</div>