feat(api): 新增节点月流量限额强制下线
新增节点级月流量限额配置、重置调度和运行状态持久化 下发 traffic_limit 给 mi-node,并在超额后停止内核、到期后恢复 管理端支持编辑限额参数并展示额度进度、状态和下次重置 手动与定时重置会同步清理限额状态并通知节点刷新配置
This commit is contained in:
Vendored
+23
@@ -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 加密方式'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user