feat(api): 新增节点月流量限额强制下线

新增节点级月流量限额配置、重置调度和运行状态持久化
下发 traffic_limit 给 mi-node,并在超额后停止内核、到期后恢复
管理端支持编辑限额参数并展示额度进度、状态和下次重置
手动与定时重置会同步清理限额状态并通知节点刷新配置
This commit is contained in:
yinjianm
2026-04-29 00:46:12 +08:00
parent 52529d1f58
commit 922e86070d
26 changed files with 1127 additions and 11 deletions
+23
View File
@@ -859,6 +859,16 @@ export interface AdminNodeMetrics {
active_connections?: number
active_users?: number
kernel_status?: boolean
traffic_limit?: {
enabled?: boolean
limit?: number
used?: number
suspended?: boolean
last_reset_at?: number
next_reset_at?: number
suspended_at?: number
status?: string
} | null
updated_at?: number
}
@@ -890,6 +900,14 @@ export interface AdminNodeItem {
gfw_check_enabled?: boolean
gfw_auto_hidden?: boolean
gfw_auto_action_at?: number | null
traffic_limit_enabled?: boolean
traffic_limit_reset_day?: number | null
traffic_limit_reset_time?: string | null
traffic_limit_timezone?: string | null
traffic_limit_status?: string | null
traffic_limit_last_reset_at?: number | null
traffic_limit_next_reset_at?: number | null
traffic_limit_suspended_at?: number | null
enabled?: boolean
parent_id?: number | null
rate?: number | null
@@ -988,6 +1006,11 @@ export interface AdminNodeSavePayload {
show?: boolean | number
auto_online?: boolean
gfw_check_enabled?: boolean
transfer_enable?: number | null
traffic_limit_enabled?: boolean
traffic_limit_reset_day?: number | null
traffic_limit_reset_time?: string | null
traffic_limit_timezone?: string | null
}
declare global {
@@ -28,6 +28,18 @@ function toNullableNumber(value: unknown): number | null {
return Number.isFinite(normalized) ? normalized : null
}
function bytesToGigabytes(value: unknown): number | null {
const normalized = Number(value)
if (!Number.isFinite(normalized) || normalized <= 0) return null
return Number((normalized / 1073741824).toFixed(2))
}
function gigabytesToBytes(value: unknown): number {
const normalized = Number(value)
if (!Number.isFinite(normalized) || normalized <= 0) return 0
return Math.round(normalized * 1073741824)
}
function toBooleanValue(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
@@ -151,6 +163,11 @@ export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel {
form.name = toStringValue(node.name)
form.code = toStringValue(node.code)
form.rate = toNumberValue(node.rate, 1)
form.trafficLimitEnabled = toBooleanValue(node.traffic_limit_enabled)
form.trafficLimitGb = bytesToGigabytes(node.transfer_enable)
form.trafficLimitResetDay = toNullableNumber(node.traffic_limit_reset_day) ?? 1
form.trafficLimitResetTime = toStringValue(node.traffic_limit_reset_time || '00:00')
form.trafficLimitTimezone = toStringValue(node.traffic_limit_timezone || 'Asia/Shanghai')
form.rateTimeEnable = toBooleanValue(node.rate_time_enable)
form.rateTimeRanges = Array.isArray(node.rate_time_ranges) && node.rate_time_ranges.length > 0
? node.rate_time_ranges.map((item, index) => ({
@@ -495,5 +512,10 @@ export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload {
show: form.show ? 1 : 0,
auto_online: form.autoOnline,
gfw_check_enabled: form.gfwCheckEnabled,
transfer_enable: form.trafficLimitEnabled ? gigabytesToBytes(form.trafficLimitGb) : 0,
traffic_limit_enabled: form.trafficLimitEnabled,
traffic_limit_reset_day: form.trafficLimitEnabled ? form.trafficLimitResetDay : null,
traffic_limit_reset_time: form.trafficLimitEnabled ? form.trafficLimitResetTime : null,
traffic_limit_timezone: form.trafficLimitEnabled ? form.trafficLimitTimezone.trim() || undefined : undefined,
}
}
@@ -21,6 +21,11 @@ export interface NodeFormModel {
name: string
code: string
rate: number
trafficLimitEnabled: boolean
trafficLimitGb: number | null
trafficLimitResetDay: number | null
trafficLimitResetTime: string
trafficLimitTimezone: string
rateTimeEnable: boolean
rateTimeRanges: NodeRateRangeForm[]
tags: string[]
@@ -233,6 +238,11 @@ export function createEmptyNodeForm(): NodeFormModel {
name: '',
code: '',
rate: 1,
trafficLimitEnabled: false,
trafficLimitGb: null,
trafficLimitResetDay: 1,
trafficLimitResetTime: '00:00',
trafficLimitTimezone: 'Asia/Shanghai',
rateTimeEnable: false,
rateTimeRanges: [createRateRange()],
tags: [],
@@ -399,6 +409,17 @@ export function validateNodeForm(form: NodeFormModel): string | null {
return '请至少填写一条有效的动态倍率规则'
}
}
if (form.trafficLimitEnabled) {
if (!Number.isFinite(Number(form.trafficLimitGb)) || Number(form.trafficLimitGb) <= 0) {
return '请输入大于 0 的月流量额度'
}
if (!Number.isInteger(Number(form.trafficLimitResetDay)) || Number(form.trafficLimitResetDay) < 1 || Number(form.trafficLimitResetDay) > 31) {
return '重置日期需为 1-31'
}
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(form.trafficLimitResetTime)) {
return '重置时间格式需为 HH:mm'
}
}
if (form.type === 'shadowsocks' && !form.shadowsocksCipher.trim()) {
return '请选择 Shadowsocks 加密方式'
}
+56
View File
@@ -29,6 +29,16 @@ export interface NodeTrafficDetail {
total: string
}
export interface NodeTrafficLimitDetail {
enabled: boolean
used: string
limit: string
percent: number
statusLabel: string
tagType: 'success' | 'warning' | 'danger' | 'info'
nextReset: string
}
type TrafficAmountLike = {
upload?: number | string | null
download?: number | string | null
@@ -261,6 +271,50 @@ export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[]
})
}
export function getNodeTrafficLimitDetail(node: AdminNodeItem): NodeTrafficLimitDetail {
const limit = normalizeTrafficValue(node.transfer_enable)
const metrics = node.metrics?.traffic_limit
const used = normalizeTrafficValue(metrics?.used) || normalizeTrafficValue(node.u) + normalizeTrafficValue(node.d)
const suspended = Boolean(metrics?.suspended) || node.traffic_limit_status === 'suspended'
const nextResetAt = normalizeTrafficValue(metrics?.next_reset_at) || normalizeTrafficValue(node.traffic_limit_next_reset_at)
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0
let statusLabel = '未启用'
let tagType: NodeTrafficLimitDetail['tagType'] = 'info'
if (Boolean(node.traffic_limit_enabled) && limit > 0) {
if (suspended) {
statusLabel = '已限额'
tagType = 'danger'
} else if (percent >= 90) {
statusLabel = '接近额度'
tagType = 'warning'
} else {
statusLabel = '正常'
tagType = 'success'
}
}
return {
enabled: Boolean(node.traffic_limit_enabled) && limit > 0,
used: formatTrafficBytes(used),
limit: formatTrafficBytes(limit),
percent,
statusLabel,
tagType,
nextReset: formatTimestamp(nextResetAt),
}
}
function formatTimestamp(value: number): string {
if (!value) return '未设置'
return new Date(value * 1000).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function getNodeGroupNames(node: AdminNodeItem): string[] {
return (node.groups ?? [])
.map((group) => group.name)
@@ -285,6 +339,8 @@ function buildNodeSearchText(node: AdminNodeItem): string {
node.server_port,
getNodeTypeLabel(node.type),
node.auto_online ? '自动上线 自动托管 auto online' : '',
node.traffic_limit_enabled ? '流量限额 月流量 超额下线 traffic limit quota' : '',
node.traffic_limit_status === 'suspended' ? '限额下线 已限额 suspended quota exceeded' : '',
node.gfw_check_enabled === false ? '关闭墙检测 关闭自动墙检 gfw disabled' : '自动墙检 墙检测托管 gfw enabled',
node.gfw_auto_hidden ? '自动隐藏 墙检测隐藏 疑似被墙已隐藏 gfw auto hidden' : '',
getNodeGfwMeta(node).searchText,
@@ -357,6 +357,68 @@ watch(
</div>
</section>
<section class="form-section">
<div class="section-head">
<div>
<h3>流量限额</h3>
<p>按节点月流量控制 mi-node 内核下线与恢复</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="启用限额" class="form-grid--full">
<div class="switch-row">
<div>
<strong>月流量限额</strong>
<span>达到额度后节点内核停止重置后恢复</span>
</div>
<ElSwitch v-model="form.trafficLimitEnabled" />
</div>
</ElFormItem>
<ElFormItem label="月流量额度(GB">
<ElInputNumber
v-model="form.trafficLimitGb"
:min="1"
:step="10"
:precision="2"
:controls="false"
:disabled="!form.trafficLimitEnabled"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="重置日期">
<ElInputNumber
v-model="form.trafficLimitResetDay"
:min="1"
:max="31"
:step="1"
:precision="0"
:controls="false"
:disabled="!form.trafficLimitEnabled"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="重置时间">
<ElTimePicker
v-model="form.trafficLimitResetTime"
value-format="HH:mm"
format="HH:mm"
placeholder="00:00"
:disabled="!form.trafficLimitEnabled"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="时区">
<ElInput
v-model="form.trafficLimitTimezone"
placeholder="Asia/Shanghai"
:disabled="!form.trafficLimitEnabled"
/>
</ElFormItem>
</div>
</section>
<section v-if="form.rateTimeEnable" class="form-section">
<div class="section-head">
<div>
@@ -47,6 +47,7 @@ import {
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTrafficLimitDetail,
getNodeTrafficDetails,
getNodeTypeLabel,
type NodeRelationFilter,
@@ -921,12 +922,36 @@ watch(
<span>下行 {{ traffic.download }}</span>
</div>
</article>
<article
v-if="getNodeTrafficLimitDetail(row).enabled"
class="node-traffic-row node-traffic-row--limit"
>
<div class="node-traffic-row__summary">
<span>月额度</span>
<strong>{{ getNodeTrafficLimitDetail(row).used }} / {{ getNodeTrafficLimitDetail(row).limit }}</strong>
</div>
<div class="node-traffic-limit-bar">
<span :style="{ width: `${getNodeTrafficLimitDetail(row).percent}%` }" />
</div>
<div class="node-traffic-row__split">
<span>{{ getNodeTrafficLimitDetail(row).statusLabel }}</span>
<span>{{ getNodeTrafficLimitDetail(row).nextReset }}</span>
</div>
</article>
</div>
</ElPopover>
<div class="node-cell__sub">
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
</ElTag>
<ElTag
v-if="getNodeTrafficLimitDetail(row).enabled"
round
effect="plain"
:type="getNodeTrafficLimitDetail(row).tagType"
>
{{ getNodeTrafficLimitDetail(row).statusLabel }}
</ElTag>
<ElTag
v-if="row.auto_online"
round
@@ -1438,6 +1463,25 @@ watch(
font-variant-numeric: tabular-nums;
}
:global(.node-traffic-row--limit) {
background: #fff7ed;
}
:global(.node-traffic-limit-bar) {
width: 100%;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
}
:global(.node-traffic-limit-bar span) {
display: block;
height: 100%;
border-radius: inherit;
background: #f97316;
}
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,