feat: 实现容量与配额安全闸 (#75)

This commit is contained in:
QiuSW
2026-08-28 11:55:11 +08:00
parent debf662138
commit 12f5419f21
26 changed files with 1193 additions and 41 deletions
+4
View File
@@ -20,6 +20,10 @@ export function disableDevice(id, data) {
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
}
export function enableDevice(id, data) {
return request({ url: `/api/v1/devices/${id}/enable`, method: 'put', data })
}
export function updateDeviceCredentials(id, data) {
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
}
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function getQuotaOverview(query) {
return request({ url: '/api/v1/quota', method: 'get', params: query })
}
export function updateQuota(data) {
return request({ url: '/api/v1/quota', method: 'put', data })
}
+10
View File
@@ -69,6 +69,7 @@
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
<el-divider direction="vertical" />
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
<el-button v-else v-permisaction="['sense:device:enable']" type="success" link size="small" @click="handleEnable(scope.row)">启用</el-button>
</template>
</el-table-column>
</el-table>
@@ -141,6 +142,7 @@ import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
addDevice,
disableDevice,
enableDevice,
getDevice,
listDevices,
updateDevice,
@@ -284,6 +286,14 @@ export default {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
},
handleEnable(row) {
this.$confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
confirmButtonText: '确认启用', cancelButtonText: '取消', type: 'warning'
}).then(() => enableDevice(row.id, disableDevicePayload(row.version))).then(response => {
this.msgSuccess(response.msg)
this.getList()
}).catch(() => {})
}
}
}
+177
View File
@@ -0,0 +1,177 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>容量与配额</h3>
<p>查看当前占用与安全闸状态。配额是交付配置,不等于单机性能承诺。</p>
</div>
<div class="header-actions">
<el-button v-permisaction="['sense:device:add']" :icon="Plus" :disabled="!gate.writable" @click="router.push('/sense/devices')">新增设备</el-button>
<el-button
v-permisaction="['sense:quota:update']"
type="primary"
:icon="Setting"
:disabled="summary.readStatus !== 'readable'"
@click="openQuotaDialog"
>调整配额</el-button>
</div>
</div>
<el-alert :title="gate.title" :type="gate.type" :closable="false" show-icon class="gate-alert">
{{ gate.detail }}
</el-alert>
<el-row :gutter="12" class="summary-row" aria-label="容量摘要">
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>当前配置配额</span><strong>{{ readableLimit }}</strong><small>来源:{{ sourceLabel }}</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>已占用设备</span><strong>{{ summary.used }} 路</strong><small>已接入和待接入均占用</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>剩余可用</span><strong>{{ readableRemaining }}</strong><small>写入时再次原子校验</small></div></el-col>
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>配额读取状态</span><strong class="read-state"><el-tag :type="summary.readStatus === 'readable' ? 'success' : 'danger'">{{ summary.readStatus === 'readable' ? '可读取' : '读取失败' }}</el-tag></strong><small>最后读取:{{ formatTime(summary.lastReadAt) }}</small></div></el-col>
</el-row>
<section class="usage-section">
<div class="section-header"><h4>占用情况</h4><span>{{ usageCaption }}</span></div>
<el-progress :percentage="percent" :status="gate.type === 'error' ? 'exception' : gate.type === 'warning' ? 'warning' : ''" />
</section>
<div class="section-header"><div><h4>设备占用明细</h4><p>停用设备不占用配额;重新启用时会执行安全闸校验。</p></div></div>
<el-form :model="query" class="filter-form" label-width="76px" @submit.prevent="search">
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option label="已接入" value="active" /><el-option label="待接入" value="pending" /><el-option label="已停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="设备名称"><el-input v-model="query.keyword" clearable placeholder="请输入设备名称或位置" @keyup.enter="search" /></el-form-item>
<el-form-item class="filter-actions"><el-button type="primary" :icon="Search" native-type="submit">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="devices" border stripe empty-text="当前没有设备">
<el-table-column prop="name" label="设备名称" min-width="160" />
<el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
<el-table-column label="接入状态" width="110" align="center"><template #default="scope"><el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ deviceStatusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="配额占用" width="110" align="center"><template #default="scope">{{ scope.row.occupied ? '占用 1 路' : '不占用' }}</template></el-table-column>
<el-table-column label="更新时间" width="180" align="center"><template #default="scope">{{ formatTime(scope.row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'disabled'"
v-permisaction="['sense:device:enable']"
type="success"
link
:disabled="!gate.writable"
@click="enable(scope.row)"
>启用</el-button>
<el-button v-else type="primary" link @click="router.push('/sense/devices')">查看</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<div class="section-header tier-header"><div><h4>交付档位与验证状态</h4><p>“未验证”不代表系统承诺可稳定承载,必须先完成目标硬件性能测试。</p></div></div>
<el-table :data="tiers" border>
<el-table-column label="档位" width="100"><template #default="scope">{{ scope.row.limit }} 路</template></el-table-column>
<el-table-column label="当前配置" width="120"><template #default="scope"><el-tag v-if="scope.row.configured" type="success">当前配置</el-tag><span v-else>未配置</span></template></el-table-column>
<el-table-column label="性能测试" width="120"><template #default="scope"><el-tag :type="scope.row.validation === 'verified' ? 'success' : 'warning'">{{ tierValidationLabel(scope.row.validation) }}</el-tag></template></el-table-column>
<el-table-column prop="deliveryStatus" label="说明" min-width="220" />
</el-table>
</el-card>
<el-dialog v-model="dialogOpen" title="调整配额" width="min(520px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="resetDialog">
<el-alert title="调整前请确认目标硬件已经完成容量测试" type="warning" :closable="false" show-icon class="dialog-alert">
降低到当前占用以下不会中断已有视频,但会阻止新增与启用,直到占用回到配额内。
</el-alert>
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="110px">
<el-form-item label="新配额(路)" prop="limit"><el-input-number v-model="quotaForm.limit" :min="1" :max="100000" controls-position="right" /></el-form-item>
<el-form-item label="变更原因" prop="reason"><el-input v-model.trim="quotaForm.reason" maxlength="512" show-word-limit placeholder="请输入变更原因" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveQuota">确认调整</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshLeft, Search, Setting } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getQuotaOverview, updateQuota } from '@/api/sense/quota'
import { enableDevice } from '@/api/sense/device'
import { buildQuotaQuery, deviceStatusLabel, quotaGate, tierValidationLabel, usagePercent } from './quotaState'
defineOptions({ name: 'SenseQuota' })
const loading = ref(false)
const router = useRouter()
const saving = ref(false)
const devices = ref([])
const tiers = ref([])
const total = ref(0)
const summary = reactive({ limit: 0, used: 0, remaining: 0, readStatus: 'unreadable', source: '', version: 0 })
const query = reactive({ pageIndex: 1, pageSize: 10, keyword: '', status: '' })
const dialogOpen = ref(false)
const quotaFormRef = ref()
const quotaForm = reactive({ limit: 16, reason: '' })
const quotaRules = {
limit: [{ required: true, type: 'number', min: 1, max: 100000, message: '请输入 1 至 100000 的整数配额', trigger: 'change' }],
reason: [{ required: true, message: '请输入变更原因,以便审计追溯', trigger: 'blur' }]
}
const gate = computed(() => quotaGate(summary))
const percent = computed(() => usagePercent(summary))
const readableLimit = computed(() => summary.readStatus === 'readable' ? `${summary.limit} 路` : '—')
const readableRemaining = computed(() => summary.readStatus === 'readable' ? `${summary.remaining} 路` : '—')
const sourceLabel = computed(() => summary.readStatus === 'readable' ? (summary.source === 'migration' ? '初始化配置' : '数据库配置') : '不可读取')
const usageCaption = computed(() => summary.readStatus === 'readable' ? `已占用 ${summary.used} / ${summary.limit} 路(${percent.value}%)` : `已占用 ${summary.used} 路;配额上限不可读取`)
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function load() {
loading.value = true
try {
const payload = unwrap(await getQuotaOverview(buildQuotaQuery(query))) || {}
Object.assign(summary, payload.summary || {})
devices.value = payload.list || []
tiers.value = payload.tiers || []
total.value = payload.count || 0
} catch (error) {
ElMessage.error(error.message || '容量与配额加载失败')
} finally {
loading.value = false
}
}
function search() { query.pageIndex = 1; load() }
function reset() { Object.assign(query, { pageIndex: 1, keyword: '', status: '' }); load() }
function openQuotaDialog() { quotaForm.limit = summary.limit; quotaForm.reason = ''; dialogOpen.value = true }
function resetDialog() { quotaForm.reason = ''; quotaFormRef.value?.clearValidate() }
async function enable(row) {
try {
await ElMessageBox.confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
type: 'warning', confirmButtonText: '确认启用', cancelButtonText: '取消'
})
await enableDevice(row.id, { version: row.version })
ElMessage.success('设备已启用,请重新完成视频接入验证')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '设备启用失败')
}
}
async function saveQuota() {
if (!await quotaFormRef.value.validate().catch(() => false)) return
saving.value = true
try {
await updateQuota({ limit: quotaForm.limit, reason: quotaForm.reason, version: summary.version })
ElMessage.success('配额已更新')
dialogOpen.value = false
await load()
} catch (error) {
ElMessage.warning(error.message || '配额更新失败,请刷新后重试')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.header-actions{display:flex;gap:8px;flex-wrap:wrap}.gate-alert{margin:16px 0}.summary-row{margin-bottom:18px}.summary-item{display:flex;flex-direction:column;min-height:108px;padding:14px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span,.summary-item small{color:#606266}.summary-item strong{margin:8px 0 4px;font-size:24px}.summary-item .read-state{font-size:16px}.usage-section{margin:4px 0 22px;padding:16px;border:1px solid #ebeef5}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:250px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.tier-header{margin-top:24px}.dialog-alert{margin-bottom:18px}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.filter-form .el-form-item{width:100%}.summary-item{margin-bottom:8px}}
</style>
@@ -0,0 +1,48 @@
export function buildQuotaQuery(query) {
return {
pageIndex: Math.max(1, Number(query.pageIndex) || 1),
pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 10)),
keyword: String(query.keyword || '').trim(),
status: String(query.status || '').trim()
}
}
export function quotaGate(summary = {}) {
if (summary.readStatus !== 'readable') {
return {
type: 'error',
title: '配额配置不可读取',
detail: '为避免超配额,新增和启用请求已关闭;已有设备、视频流与查询不受影响。请检查配置来源后重试。',
writable: false
}
}
if ((Number(summary.remaining) || 0) <= 0) {
return {
type: 'warning',
title: '当前配额已满',
detail: '新增和启用请求将被拒绝;已有设备、视频流与查询继续正常运行。',
writable: false
}
}
return {
type: 'success',
title: '安全闸正常',
detail: '新增和启用设备可继续;系统会在写入时再次原子校验剩余配额。',
writable: true
}
}
export function usagePercent(summary = {}) {
const limit = Number(summary.limit) || 0
const used = Number(summary.used) || 0
if (limit <= 0) return 0
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)))
}
export function deviceStatusLabel(status) {
return ({ active: '已接入', pending: '待接入', disabled: '已停用' })[status] || status || '未知'
}
export function tierValidationLabel(value) {
return value === 'verified' ? '已验证' : '未验证'
}