feat(api): 新增节点流量悬浮详情与即时自动上线同步

为 server/manage/getNodes 返回节点级今日、本月与累计流量统计,
并在节点管理页名称悬浮层展示上行、下行和合计流量。

同时为自动上线补齐单节点同步入口,在管理端保存、
批量更新以及 REST/WS 心跳后立即同步 show 状态,
避免复制节点后开启自动上线仍需等待定时任务。

另优化管理端前端 Docker 发布流程,默认仅构建 amd64,
并收敛 BuildKit 缓存导出以缩短发布时间
This commit is contained in:
yinjianm
2026-04-28 16:51:35 +08:00
parent a62a124710
commit 1739f7a2f9
21 changed files with 966 additions and 65 deletions
+10
View File
@@ -862,6 +862,12 @@ export interface AdminNodeMetrics {
updated_at?: number
}
export interface AdminNodeTrafficStats {
today: TrafficAmount
month: TrafficAmount
total: TrafficAmount
}
export interface AdminNodeRateTimeRange {
start: string
end: string
@@ -887,6 +893,9 @@ export interface AdminNodeItem {
enabled?: boolean
parent_id?: number | null
rate?: number | null
transfer_enable?: number | null
u?: number | null
d?: number | null
rate_time_enable?: boolean
rate_time_ranges?: AdminNodeRateTimeRange[] | null
sort?: number | null
@@ -898,6 +907,7 @@ export interface AdminNodeItem {
last_check_at?: number | null
last_push_at?: number | null
metrics?: AdminNodeMetrics | null
traffic_stats?: AdminNodeTrafficStats | null
groups?: AdminServerGroupItem[]
parent?: AdminNodeParentRef | null
gfw_check?: AdminNodeGfwCheck | null
+1
View File
@@ -35,6 +35,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
+82 -1
View File
@@ -1,8 +1,9 @@
import type { AdminNodeItem } from '@/types/api'
import type { AdminNodeItem, TrafficAmount } from '@/types/api'
export type NodeRelationFilter = 'all' | 'parent' | 'child'
export type NodeGfwFilter = 'all' | 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking' | 'inherited'
export type NodeStatusFilter = 'all' | 'online' | 'offline'
export type NodeVisibilityFilter = 'all' | 'visible' | 'hidden'
export interface NodeStatusMeta {
label: string
@@ -20,6 +21,20 @@ export interface NodeGfwMeta {
inherited: boolean
}
export interface NodeTrafficDetail {
key: 'today' | 'month' | 'total'
label: string
upload: string
download: string
total: string
}
type TrafficAmountLike = {
upload?: number | string | null
download?: number | string | null
total?: number | string | null
}
const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
@@ -34,10 +49,41 @@ const NODE_TYPE_LABELS: Record<string, string> = {
mieru: 'Mieru',
}
const TRAFFIC_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
function normalizeText(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
function normalizeTrafficValue(value: unknown): number {
const normalized = Number(value)
return Number.isFinite(normalized) && normalized > 0 ? normalized : 0
}
function normalizeTrafficAmount(amount?: TrafficAmountLike | null): TrafficAmount {
const upload = normalizeTrafficValue(amount?.upload)
const download = normalizeTrafficValue(amount?.download)
const total = normalizeTrafficValue(amount?.total) || upload + download
return { upload, download, total }
}
export function formatTrafficBytes(value?: number | string | null): string {
let amount = normalizeTrafficValue(value)
let unitIndex = 0
while (amount >= 1024 && unitIndex < TRAFFIC_UNITS.length - 1) {
amount /= 1024
unitIndex += 1
}
if (unitIndex === 0) {
return `${Math.round(amount)} B`
}
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(2)} ${TRAFFIC_UNITS[unitIndex]}`
}
export function getNodeTypeLabel(type: string): string {
const normalized = normalizeText(type)
return NODE_TYPE_LABELS[normalized] ?? String(type || '未知协议').toUpperCase()
@@ -190,6 +236,31 @@ export function formatNodeRate(rate?: number | null): string {
return `${normalized.toFixed(2)} x`
}
export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[] {
const stats = node.traffic_stats
const totalFallback = normalizeTrafficAmount({
upload: node.u ?? 0,
download: node.d ?? 0,
})
const rows: Array<{ key: NodeTrafficDetail['key']; label: string; source?: TrafficAmountLike | null }> = [
{ key: 'today', label: '今日', source: stats?.today },
{ key: 'month', label: '本月', source: stats?.month },
{ key: 'total', label: '累计', source: stats?.total ?? totalFallback },
]
return rows.map((row) => {
const amount = normalizeTrafficAmount(row.source)
return {
key: row.key,
label: row.label,
upload: formatTrafficBytes(amount.upload),
download: formatTrafficBytes(amount.download),
total: formatTrafficBytes(amount.total),
}
})
}
export function getNodeGroupNames(node: AdminNodeItem): string[] {
return (node.groups ?? [])
.map((group) => group.name)
@@ -231,6 +302,7 @@ export function filterNodes(
typeFilter: string,
groupFilter: string,
statusFilter: NodeStatusFilter = 'all',
visibilityFilter: NodeVisibilityFilter = 'all',
relationFilter: NodeRelationFilter = 'all',
gfwFilter: NodeGfwFilter = 'all',
): AdminNodeItem[] {
@@ -238,6 +310,7 @@ export function filterNodes(
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedStatus = normalizeText(statusFilter)
const normalizedVisibility = normalizeText(visibilityFilter)
const normalizedRelation = normalizeText(relationFilter)
const normalizedGfw = normalizeText(gfwFilter)
@@ -266,6 +339,14 @@ export function filterNodes(
return false
}
if (normalizedVisibility === 'visible' && !Boolean(node.show)) {
return false
}
if (normalizedVisibility === 'hidden' && Boolean(node.show)) {
return false
}
if (normalizedRelation === 'parent' && node.parent_id) {
return false
}
+138 -5
View File
@@ -47,10 +47,12 @@ import {
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTrafficDetails,
getNodeTypeLabel,
type NodeRelationFilter,
type NodeGfwFilter,
type NodeStatusFilter,
type NodeVisibilityFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
@@ -70,6 +72,7 @@ const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const statusFilter = ref<NodeStatusFilter>('all')
const visibilityFilter = ref<NodeVisibilityFilter>('all')
const relationFilter = ref<NodeRelationFilter>('all')
const gfwFilter = ref<NodeGfwFilter>('all')
const currentPage = ref(1)
@@ -99,6 +102,7 @@ const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
typeFilter.value,
groupFilter.value,
statusFilter.value,
visibilityFilter.value,
relationFilter.value,
gfwFilter.value,
)))
@@ -116,6 +120,7 @@ const hasActiveFilters = computed(() => (
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| statusFilter.value !== 'all'
|| visibilityFilter.value !== 'all'
|| relationFilter.value !== 'all'
|| gfwFilter.value !== 'all'
))
@@ -304,6 +309,7 @@ function handleReset() {
typeFilter.value = 'all'
groupFilter.value = 'all'
statusFilter.value = 'all'
visibilityFilter.value = 'all'
relationFilter.value = 'all'
gfwFilter.value = 'all'
currentPage.value = 1
@@ -647,7 +653,7 @@ watch(
},
)
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
watch([keyword, typeFilter, groupFilter, statusFilter, visibilityFilter, relationFilter, gfwFilter], () => {
currentPage.value = 1
})
@@ -735,6 +741,12 @@ watch(
<ElOption label="离线节点" value="offline" />
</ElSelect>
<ElSelect v-model="visibilityFilter" class="toolbar-select" placeholder="显隐">
<ElOption label="全部显隐" value="all" />
<ElOption label="显示中" value="visible" />
<ElOption label="已隐藏" value="hidden" />
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
@@ -878,10 +890,39 @@ watch(
<ElTableColumn label="节点" min-width="280">
<template #default="{ row }">
<div class="node-cell">
<div class="node-cell__main">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</div>
<ElPopover
placement="right-start"
trigger="hover"
popper-class="node-traffic-popover"
:width="360"
>
<template #reference>
<button class="node-cell__main node-name-trigger" type="button">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</button>
</template>
<div class="node-traffic-card">
<header class="node-traffic-card__header">
<span>流量统计</span>
<strong>{{ row.name }}</strong>
</header>
<article
v-for="traffic in getNodeTrafficDetails(row)"
:key="`${row.id}-${traffic.key}`"
class="node-traffic-row"
>
<div class="node-traffic-row__summary">
<span>{{ traffic.label }}</span>
<strong>{{ traffic.total }}</strong>
</div>
<div class="node-traffic-row__split">
<span>上行 {{ traffic.upload }}</span>
<span>下行 {{ traffic.download }}</span>
</div>
</article>
</div>
</ElPopover>
<div class="node-cell__sub">
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
@@ -1227,6 +1268,32 @@ watch(
gap: 8px;
}
.node-name-trigger {
width: fit-content;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
cursor: default;
}
.node-name-trigger strong {
transition: color 0.18s ease;
}
.node-name-trigger:hover strong,
.node-name-trigger:focus-visible strong {
color: #0071e3;
}
.node-name-trigger:focus-visible {
outline: 2px solid #0071e3;
outline-offset: 3px;
border-radius: 8px;
}
.node-cell__sub {
flex-wrap: wrap;
}
@@ -1305,6 +1372,72 @@ watch(
color: var(--xboard-text-muted);
}
:global(.node-traffic-popover) {
padding: 0 !important;
border: 0 !important;
border-radius: 18px !important;
box-shadow: rgba(0, 0, 0, 0.18) 0 12px 36px 0 !important;
}
:global(.node-traffic-popover .node-traffic-card) {
display: grid;
gap: 10px;
padding: 14px;
color: #1d1d1f;
}
:global(.node-traffic-card__header) {
display: grid;
gap: 3px;
padding: 2px 2px 6px;
}
:global(.node-traffic-card__header span) {
color: rgba(0, 0, 0, 0.48);
font-size: 12px;
line-height: 1.33;
}
:global(.node-traffic-card__header strong) {
color: #1d1d1f;
font-size: 15px;
line-height: 1.25;
}
:global(.node-traffic-row) {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: #f5f5f7;
}
:global(.node-traffic-row__summary),
:global(.node-traffic-row__split) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
:global(.node-traffic-row__summary span),
:global(.node-traffic-row__split span) {
color: rgba(0, 0, 0, 0.56);
font-size: 12px;
line-height: 1.33;
}
:global(.node-traffic-row__summary strong) {
color: #0071e3;
font-size: 17px;
line-height: 1.19;
font-variant-numeric: tabular-nums;
}
:global(.node-traffic-row__split span) {
font-variant-numeric: tabular-nums;
}
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,