From 916a4f5ffc07c5131bee6cec1459596c4bc9de1f Mon Sep 17 00:00:00 2001 From: feie9454 Date: Wed, 6 May 2026 00:10:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/admin-nav.tsx | 2 - app/admin/audits/page.tsx | 57 +- app/admin/dashboard/dashboard-charts.tsx | 676 +++++++++++++++++ app/admin/dashboard/page.tsx | 35 +- app/admin/layout.tsx | 16 +- app/admin/nodes/page.tsx | 525 ++++++------- app/admin/subscriptions/actions.ts | 33 +- app/admin/subscriptions/page.tsx | 22 +- .../subscriptions/proxy-group-editor.tsx | 222 +++--- app/admin/users/page.tsx | 327 ++++---- app/globals.css | 711 ++++++++++++++++-- app/login/page.tsx | 13 +- app/me/page.tsx | 48 +- bun.lock | 79 ++ lib/datetime.ts | 20 + lib/db.ts | 1 + lib/store.ts | 329 +++++++- lib/subscription.ts | 37 +- package.json | 1 + 19 files changed, 2431 insertions(+), 723 deletions(-) create mode 100644 app/admin/dashboard/dashboard-charts.tsx create mode 100644 lib/datetime.ts diff --git a/app/admin/admin-nav.tsx b/app/admin/admin-nav.tsx index 473936b..351ada1 100644 --- a/app/admin/admin-nav.tsx +++ b/app/admin/admin-nav.tsx @@ -6,7 +6,6 @@ import { usePathname } from "next/navigation"; type NavItem = { href: string; label: string; - meta: string; }; export function AdminNav({ items }: { items: NavItem[] }) { @@ -24,7 +23,6 @@ export function AdminNav({ items }: { items: NavItem[] }) { className={`nav-link ${active ? "is-active" : ""}`} > {item.label} - {item.meta} ); })} diff --git a/app/admin/audits/page.tsx b/app/admin/audits/page.tsx index c403aae..56420a9 100644 --- a/app/admin/audits/page.tsx +++ b/app/admin/audits/page.tsx @@ -1,15 +1,15 @@ +import { formatLocalDateTime } from "@/lib/datetime"; import { formatBytes, listAudits } from "@/lib/store"; export default async function AuditsPage() { const audits = await listAudits(); return ( -
+
- AUDIT + 审计

鉴权审计

-

记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。

@@ -19,33 +19,38 @@ export default async function AuditsPage() {
-
-
+
+
+
+ 结果 + 节点 / 用户 + 来源 + 请求速率 + 原因 + 时间 +
{audits.map((audit) => ( -
-
-
- - {audit.ok ? "允许" : "拒绝"} - -
-

- {audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"} -

-

{audit.addr ?? "-"}

-
-
-

{audit.reason}

-

- 请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"} - /s -

-

{audit.created_at}

-
+
+ + + {audit.ok ? "允许" : "拒绝"} + + + + {audit.node_name ?? "未知节点"} + {audit.username ?? audit.auth_id ?? "-"} + + {audit.addr ?? "-"} + + {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"} + /s + + {audit.reason} + {formatLocalDateTime(audit.created_at)}
))}
); -} +} \ No newline at end of file diff --git a/app/admin/dashboard/dashboard-charts.tsx b/app/admin/dashboard/dashboard-charts.tsx new file mode 100644 index 0000000..e5b8984 --- /dev/null +++ b/app/admin/dashboard/dashboard-charts.tsx @@ -0,0 +1,676 @@ +"use client"; + +import { startTransition, useId, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type NodeHealth = { + id: number; + name: string; + slug: string; + enabled: number; + last_error_message: string | null; +}; + +type TrafficTrendRow = { + bucket_start: string; + node_id: number; + auth_id: string; + tx_bytes: number; + rx_bytes: number; + total_bytes: number; +}; + +type TrafficUserOption = { + auth_id: string; + username: string; + total_bytes: number; +}; + +type LoadTrendRow = { + bucket_start: string; + node_id: number; + online_users: number; + stream_count: number; +}; + +type FailureTrendRow = { + bucket_start: string; + node_id: number; + failed_count: number; +}; + +type TopConsumerRow = { + node_id: number; + auth_id: string; + username: string; + tx_bytes: number; + rx_bytes: number; + total_bytes: number; +}; + +type DashboardChartsProps = { + nodeHealth: NodeHealth[]; + trafficTrend: TrafficTrendRow[]; + trafficBuckets: string[]; + trafficUserOptions: TrafficUserOption[]; + loadTrend: LoadTrendRow[]; + failureTrend: FailureTrendRow[]; + topConsumerRows: TopConsumerRow[]; +}; + +type TrafficPoint = { + bucketStart: string; + label: string; + [key: string]: string | number; +}; + +const nodePalette = [ + "#c96233", + "#2b6c80", + "#5d7b63", + "#7a3116", + "#6f5aa8", + "#9c6b12", + "#2f5d52", + "#8a3f55", +]; + +const axisTick = { + fill: "#3f4b57", + fontSize: 11, +}; + +const tooltipStyle = { + borderRadius: 12, + border: "1px solid rgba(24, 34, 44, 0.12)", + backgroundColor: "rgba(255, 251, 245, 0.96)", + boxShadow: "0 12px 34px rgba(51, 35, 18, 0.1)", +}; + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + + const units = ["KB", "MB", "GB", "TB", "PB"]; + let value = bytes / 1024; + let unit = units[0]; + + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`; +} + +function formatCompactBytes(bytes: number) { + if (bytes < 1024) return `${bytes}B`; + + const units = ["KB", "MB", "GB", "TB", "PB"]; + let value = bytes / 1024; + let unit = units[0]; + + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}${unit}`; +} + +function formatBucketLabel(value: string, mode: "hour" | "day") { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + ...(mode === "hour" + ? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" } + : {}), + }).format(date); +} + +function truncateLabel(value: string, maxLength = 12) { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}…`; +} + +function getNodeColor(index: number) { + return nodePalette[index % nodePalette.length]; +} + +function getNodeDataKey(nodeId: number) { + return `node_${nodeId}`; +} + +function matchesUser(row: { auth_id: string }, selectedAuthId: string) { + return selectedAuthId === "all" || row.auth_id === selectedAuthId; +} + +function buildNodeTrafficSeries( + rows: TrafficTrendRow[], + buckets: string[], + nodes: NodeHealth[], + selectedAuthId: string, +) { + const series = buckets.map((bucketStart) => { + const point: TrafficPoint = { + bucketStart, + label: formatBucketLabel(bucketStart, "hour"), + }; + + for (const node of nodes) { + point[getNodeDataKey(node.id)] = 0; + } + + return point; + }); + const pointMap = new Map(series.map((point) => [point.bucketStart, point])); + + for (const row of rows) { + if (!matchesUser(row, selectedAuthId)) { + continue; + } + + const point = pointMap.get(row.bucket_start); + + if (!point) { + continue; + } + + const key = getNodeDataKey(row.node_id); + point[key] = Number(point[key] ?? 0) + row.total_bytes; + } + + return series; +} + +function aggregateTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) { + return rows.reduce( + (accumulator, row) => { + if (!matchesUser(row, selectedAuthId)) { + return accumulator; + } + + return { + txBytes: accumulator.txBytes + row.tx_bytes, + rxBytes: accumulator.rxBytes + row.rx_bytes, + totalBytes: accumulator.totalBytes + row.total_bytes, + }; + }, + { txBytes: 0, rxBytes: 0, totalBytes: 0 }, + ); +} + +function aggregateNodeTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) { + const totals = new Map(); + + for (const row of rows) { + if (!matchesUser(row, selectedAuthId)) { + continue; + } + + totals.set(row.node_id, (totals.get(row.node_id) ?? 0) + row.total_bytes); + } + + return totals; +} + +function aggregateLoadSeries(rows: LoadTrendRow[]) { + const map = new Map(); + + for (const row of rows) { + const existing = map.get(row.bucket_start) ?? { + bucketStart: row.bucket_start, + onlineUsers: 0, + streamCount: 0, + }; + + existing.onlineUsers += row.online_users; + existing.streamCount += row.stream_count; + map.set(row.bucket_start, existing); + } + + return Array.from(map.values()) + .sort((left, right) => left.bucketStart.localeCompare(right.bucketStart)) + .map((item) => ({ + ...item, + label: formatBucketLabel(item.bucketStart, "hour"), + })); +} + +function aggregateFailureSeries(rows: FailureTrendRow[]) { + const map = new Map(); + + for (const row of rows) { + const existing = map.get(row.bucket_start) ?? { + bucketStart: row.bucket_start, + failedCount: 0, + }; + + existing.failedCount += row.failed_count; + map.set(row.bucket_start, existing); + } + + return Array.from(map.values()) + .sort((left, right) => left.bucketStart.localeCompare(right.bucketStart)) + .map((item) => ({ + ...item, + label: formatBucketLabel(item.bucketStart, "day"), + })); +} + +function aggregateTopConsumers(rows: TopConsumerRow[], selectedAuthId: string) { + const map = new Map(); + + for (const row of rows) { + if (!matchesUser(row, selectedAuthId)) { + continue; + } + + const existing = map.get(row.auth_id) ?? { + authId: row.auth_id, + username: row.username, + txBytes: 0, + rxBytes: 0, + totalBytes: 0, + }; + + existing.txBytes += row.tx_bytes; + existing.rxBytes += row.rx_bytes; + existing.totalBytes += row.total_bytes; + map.set(row.auth_id, existing); + } + + return Array.from(map.values()) + .sort((left, right) => right.totalBytes - left.totalBytes) + .slice(0, 6) + .map((item) => ({ + ...item, + label: truncateLabel(item.username), + })); +} + +export function DashboardCharts({ + nodeHealth, + trafficTrend, + trafficBuckets, + trafficUserOptions, + loadTrend, + failureTrend, + topConsumerRows, +}: DashboardChartsProps) { + const userSelectId = useId(); + const [selectedAuthId, setSelectedAuthId] = useState("all"); + const [hiddenNodeIds, setHiddenNodeIds] = useState>(() => new Set()); + const selectedUser = selectedAuthId === "all" + ? null + : trafficUserOptions.find((user) => user.auth_id === selectedAuthId) ?? null; + const selectedUserLabel = selectedUser?.username ?? "全部用户"; + + const trafficSeries = buildNodeTrafficSeries( + trafficTrend, + trafficBuckets, + nodeHealth, + selectedAuthId, + ); + const trafficTotals = aggregateTrafficTotals(trafficTrend, selectedAuthId); + const nodeTrafficTotals = aggregateNodeTrafficTotals(trafficTrend, selectedAuthId); + const loadSeries = aggregateLoadSeries(loadTrend); + const failureSeries = aggregateFailureSeries(failureTrend); + const consumerSeries = aggregateTopConsumers(topConsumerRows, selectedAuthId); + const visibleNodes = nodeHealth.filter((node) => !hiddenNodeIds.has(node.id)); + + const peakLoad = loadSeries.reduce( + (accumulator, item) => ({ + onlineUsers: Math.max(accumulator.onlineUsers, item.onlineUsers), + streamCount: Math.max(accumulator.streamCount, item.streamCount), + }), + { onlineUsers: 0, streamCount: 0 }, + ); + const totalFailures = failureSeries.reduce( + (accumulator, item) => accumulator + item.failedCount, + 0, + ); + const healthyNodes = nodeHealth.filter( + (node) => node.enabled && !node.last_error_message, + ).length; + const disabledNodes = nodeHealth.filter((node) => !node.enabled).length; + const degradedNodes = nodeHealth.length - healthyNodes - disabledNodes; + const statusTone = degradedNodes > 0 ? "danger" : disabledNodes > 0 ? "warm" : "ok"; + const statusLabel = `${healthyNodes}/${nodeHealth.length} 节点健康`; + const hasTrafficData = trafficTotals.totalBytes > 0; + const hasVisibleTrafficData = visibleNodes.some((node) => { + const key = getNodeDataKey(node.id); + return trafficSeries.some((point) => Number(point[key] ?? 0) > 0); + }); + const hasLoadData = loadSeries.some( + (item) => item.onlineUsers > 0 || item.streamCount > 0, + ); + const hasFailureData = failureSeries.some((item) => item.failedCount > 0); + const hasConsumerData = consumerSeries.length > 0; + + function toggleNodeLine(nodeId: number) { + startTransition(() => { + setHiddenNodeIds((current) => { + const next = new Set(current); + + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + + return next; + }); + }); + } + + return ( +
+
+
+
+
+

流量与节点趋势

+
+
+
+ {selectedUserLabel} + {statusLabel} +
+
+
+ + +
+
+ +
+
+ 24h 总流量 + {formatBytes(trafficTotals.totalBytes)} + TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)} +
+
+ 24h 峰值在线 + {peakLoad.onlineUsers} + 峰值流数 {peakLoad.streamCount} +
+
+ 14d 鉴权失败 + {totalFailures} + 失败请求数 +
+
+ 节点状态 + {statusLabel} + 异常 {degradedNodes} / 已禁用 {disabledNodes} +
+
+ +
+
+
+
+

节点流量

+ 最近 24 小时 +
+
+ {nodeHealth.map((node, index) => { + const hidden = hiddenNodeIds.has(node.id); + const color = getNodeColor(index); + + return ( + + ); + })} +
+
+ {hasTrafficData && hasVisibleTrafficData ? ( +
+ + + + + + [ + formatBytes(Number(value ?? 0)), + String(name), + ]} + labelFormatter={(label) => `时间 ${label}`} + /> + {visibleNodes.map((node, index) => ( + + ))} + + +
+ ) : ( +
暂无可见节点流量。
+ )} +
+ +
+
+

在线负载

+ 24h +
+ {hasLoadData ? ( +
+ + + + + + [ + `${Number(value ?? 0)}`, + String(name) === "onlineUsers" ? "在线用户" : "流数量", + ]} + labelFormatter={(label) => `时间 ${label}`} + /> + + + + +
+ ) : ( +
暂无同步快照。
+ )} +
+ +
+
+

鉴权失败

+ 14d +
+ {hasFailureData ? ( +
+ + + + + + [`${Number(value ?? 0)} 次`, "失败次数"]} + labelFormatter={(label) => `日期 ${label}`} + /> + + + +
+ ) : ( +
暂无鉴权失败。
+ )} +
+ +
+
+

用户流量排行

+ 7d +
+ {hasConsumerData ? ( +
+ + + + + + { + const payload = item?.payload as { username?: string } | undefined; + + return [ + formatBytes(Number(value ?? 0)), + String(name) === "totalBytes" + ? payload?.username ?? "累计流量" + : String(name), + ]; + }} + labelFormatter={() => "用户"} + /> + + + +
+ ) : ( +
暂无用户流量。
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 2aea561..002c808 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -1,5 +1,8 @@ +import { formatLocalDateTime } from "@/lib/datetime"; import { formatBytes, getDashboardMetrics } from "@/lib/store"; +import { DashboardCharts } from "./dashboard-charts"; + function getNodePill(node: { enabled: number; last_error_message: string | null; @@ -19,17 +22,13 @@ export default async function DashboardPage() { const data = await getDashboardMetrics(); return ( -
+
- OVERVIEW + 概览

仪表盘

-

- 汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。 -

- HTTP 鉴权在线 - 轮询聚合已启用 + 在线 {data.totals.total_online_connections} 节点 {data.totals.enabled_nodes}
@@ -59,15 +58,25 @@ export default async function DashboardPage() {
启用节点 {data.totals.enabled_nodes} - 轮询与 kick 生效范围 + 当前可用
累计流量 {formatBytes(data.totals.total_traffic)} - 已聚合到 SQLite + 全量累计
+ +
@@ -90,7 +99,7 @@ export default async function DashboardPage() { 在线 {node.last_online_users} / 流 {node.last_stream_count}

- 最近成功 {node.last_sync_ok_at ?? "无"} + 最近成功 {formatLocalDateTime(node.last_sync_ok_at)}

{node.last_error_message ? (

{node.last_error_message}

@@ -138,7 +147,7 @@ export default async function DashboardPage() {

{item.addr ?? "-"}

-

{item.created_at}

+

{formatLocalDateTime(item.created_at)}

))} @@ -160,7 +169,7 @@ export default async function DashboardPage() {

+{formatBytes(item.tx_bytes + item.rx_bytes)}

-

{item.created_at}

+

{formatLocalDateTime(item.created_at)}

))} @@ -181,7 +190,7 @@ export default async function DashboardPage() { {item.node_name}

{item.error_message ?? "unknown error"}

-

{item.created_at}

+

{formatLocalDateTime(item.created_at)}

)) )} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 12856b5..eeea93e 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -3,11 +3,11 @@ import { requireAdminAccess } from "@/lib/session"; import { AdminNav } from "./admin-nav"; const navItems = [ - { href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" }, - { href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" }, - { href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" }, - { href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" }, - { href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" }, + { href: "/admin/dashboard", label: "仪表盘" }, + { href: "/admin/users", label: "用户管理" }, + { href: "/admin/nodes", label: "节点管理" }, + { href: "/admin/subscriptions", label: "订阅配置" }, + { href: "/admin/audits", label: "审计日志" }, ]; export default async function AdminLayout({ @@ -23,9 +23,9 @@ export default async function AdminLayout({
HY2 PANEL

鹅梯控制台

-

- 管理员 {session.username} 已登录 -

+
+ {session.username} +
diff --git a/app/admin/nodes/page.tsx b/app/admin/nodes/page.tsx index 1d43e4d..8fc3b76 100644 --- a/app/admin/nodes/page.tsx +++ b/app/admin/nodes/page.tsx @@ -1,4 +1,5 @@ import { getRequestOrigin } from "@/lib/request-origin"; +import { formatLocalDateTime } from "@/lib/datetime"; import { buildNodeHy2Config } from "@/lib/subscription"; import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store"; @@ -23,17 +24,14 @@ export default async function NodesPage() { const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]); return ( -
+
- NODES + 节点

节点管理

-

- 统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。 -

- URL 自动推导 - Secret 自动生成 + 启用 {nodes.filter((node) => node.enabled).length} + 总数 {nodes.length}
@@ -48,300 +46,251 @@ export default async function NodesPage() {
-
-
-

新增节点

-
- - - - - - - - - - - - - - - - - - - +
+
+ +
+

新增节点

+
+ 展开 +
+
+ + + + + + + + + + + + + + + + + +
+
-
- {nodes.map((node) => { - const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin); - const suggestedTrafficStatsUrl = buildTrafficStatsUrl( - node.server_host, - node.traffic_stats_listen, - ); +
+
+

节点列表

+ {nodes.length} 个节点 +
+
+
+ 节点 + 状态 + 对外地址 + trafficStats + 在线/流 + 最近同步 + 操作 +
+ {nodes.map((node) => { + const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin); + const suggestedTrafficStatsUrl = buildTrafficStatsUrl( + node.server_host, + node.traffic_stats_listen, + ); - return ( -
-
-
-

{node.name}

-

/{node.slug}

-
-
-
+ return ( +
+ + + {node.name} + /{node.slug} + + {getNodePills(node).map((pill) => ( {pill.label} ))} + + + {node.server_host}:{node.server_port} + hy2 {node.hy2_listen} + + + {node.traffic_stats_url} + {node.traffic_stats_listen} + + + {node.last_online_users} / {node.last_stream_count} + 用户 / 流 + + + {formatLocalDateTime(node.last_sync_ok_at)} + {node.last_error_at ? 错误 {formatLocalDateTime(node.last_error_at)} : null} + + 编辑 + +
+
+
+ 鉴权 URL + {authUrl} +
+
+ 自动推导 trafficStats + {suggestedTrafficStatsUrl} +
-

- 最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "} - {node.last_error_at ?? "无"} -

-
-
-
-
- hy2 监听 - {node.hy2_listen} -
-
- 对外地址 - {node.server_host}:{node.server_port} -
-
- trafficStats - {node.traffic_stats_url} -
-
-
- - - - - - - - - - - - - - - - - - -
+
+ + + + + + + + + + + + + + + + + + +
-
- 节点接入说明 -

- hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。 -

-
+                  
+ 查看 hy2 config.yaml +
 {buildNodeHy2Config(node, origin)}
-                
-

- 面板轮询地址:{node.traffic_stats_url} -

-

- 自动推导地址:{suggestedTrafficStatsUrl} -

-

- 鉴权 URL:{authUrl} -

- - 下载 hy2 config.yaml - - {node.last_error_message ? ( -

{node.last_error_message}

- ) : null} -
+ + + 下载 hy2 config.yaml + + -
- - -
-
- ); - })} + {node.last_error_message ? ( +

{node.last_error_message}

+ ) : null} + +
+
+ + +
+
+
+ + ); + })} +
); -} +} \ No newline at end of file diff --git a/app/admin/subscriptions/actions.ts b/app/admin/subscriptions/actions.ts index f2fc0cc..f78621c 100644 --- a/app/admin/subscriptions/actions.ts +++ b/app/admin/subscriptions/actions.ts @@ -6,10 +6,25 @@ import { createStableId } from "@/lib/password"; import { requireAdminAccess } from "@/lib/session"; import { getSubscriptionSettings, + saveMainProxyGroupMemberSlugs, saveProxyGroups, saveSubscriptionSettings, } from "@/lib/store"; +function readUniqueMemberSlugs(formData: FormData) { + const seen = new Set(); + + return formData.getAll("member_slugs").flatMap((value) => { + const slug = String(value).trim(); + if (!slug || seen.has(slug)) { + return []; + } + + seen.add(slug); + return [slug]; + }); +} + export async function saveSubscriptionSettingsAction(formData: FormData) { await requireAdminAccess(); @@ -28,16 +43,7 @@ export async function saveProxyGroupAction(formData: FormData) { const settings = await getSubscriptionSettings(); const id = String(formData.get("id") ?? "").trim() || createStableId(); const name = String(formData.get("name") ?? "").trim(); - const seen = new Set(); - const memberSlugs = formData.getAll("member_slugs").flatMap((value) => { - const slug = String(value).trim(); - if (!slug || seen.has(slug)) { - return []; - } - - seen.add(slug); - return [slug]; - }); + const memberSlugs = readUniqueMemberSlugs(formData); if (!name) { return; @@ -62,6 +68,13 @@ export async function saveProxyGroupAction(formData: FormData) { revalidatePath("/admin/subscriptions"); } +export async function saveMainProxyGroupAction(formData: FormData) { + await requireAdminAccess(); + + await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData)); + revalidatePath("/admin/subscriptions"); +} + export async function deleteProxyGroupAction(formData: FormData) { await requireAdminAccess(); diff --git a/app/admin/subscriptions/page.tsx b/app/admin/subscriptions/page.tsx index ff703f6..b17f206 100644 --- a/app/admin/subscriptions/page.tsx +++ b/app/admin/subscriptions/page.tsx @@ -29,13 +29,10 @@ export default async function AdminSubscriptionsPage() {
- SUBSCRIPTIONS + 订阅

订阅与模板

-

- 管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。 -

- 动态 proxies + 节点 {nodes.length} 分组 {settings.proxyGroups.length}
@@ -69,11 +66,20 @@ export default async function AdminSubscriptionsPage() {
-

Proxy Groups

-

已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。

+

代理分组

+ + {settings.proxyGroups.map((group) => ( ))} @@ -84,7 +90,7 @@ export default async function AdminSubscriptionsPage() {
-

Clash 模板

+

订阅模板

-
- 保存后会立即更新订阅模板里的 proxy-groups。 - +
+
- {group ? ( + {group && !isMainGroup ? (
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index acb934d..485b966 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,4 +1,5 @@ import { getRequestOrigin } from "@/lib/request-origin"; +import { formatLocalDateTime } from "@/lib/datetime"; import { buildSubscriptionUrl, describeRemainingTraffic, @@ -30,7 +31,7 @@ function getUserPills(user: { ? { label: "管理员", className: "status-pill status-pill--ink" } : null, user.expires_at - ? { label: 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)); } @@ -39,17 +40,14 @@ export default async function UsersPage() { const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]); return ( -
+
- USERS + 用户

用户管理

-

- 管理用户名:密码、`auth_id`、到期时间、额度、订阅名和用户自助链接。 -

- 自助登录 - 订阅链接按用户独立生成 + 启用 {users.filter((user) => user.enabled).length} + 总数 {users.length}
@@ -64,157 +62,174 @@ export default async function UsersPage() {
-
-
-

新建用户

-
- - - - - -