@@ -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 : 16 px } . page - header h3 , . section - header h4 { margin : 0 0 6 px } . page - header p , . section - header p { margin : 0 ; color : # 909399 } . header - actions { display : flex ; gap : 8 px ; flex - wrap : wrap } . gate - alert { margin : 16 px 0 } . summary - row { margin - bottom : 18 px } . summary - item { display : flex ; flex - direction : column ; min - height : 108 px ; padding : 14 px 16 px ; border : 1 px solid # ebeef5 ; border - radius : 4 px } . summary - item span , . summary - item small { color : # 606266 } . summary - item strong { margin : 8 px 0 4 px ; font - size : 24 px } . summary - item . read - state { font - size : 16 px } . usage - section { margin : 4 px 0 22 px ; padding : 16 px ; border : 1 px solid # ebeef5 } . filter - form { display : flex ; align - items : flex - end ; flex - wrap : wrap ; gap : 0 12 px ; margin : 16 px 0 2 px } . filter - form . el - form - item { width : 250 px } . filter - form . filter - actions { width : auto } . filter - form : deep ( . el - select ) { width : 100 % } . tier - header { margin - top : 24 px } . dialog - alert { margin - bottom : 18 px } @ media ( max - width : 768 px ) { . page - header { align - items : stretch ; flex - direction : column } . filter - form . el - form - item { width : 100 % } . summary - item { margin - bottom : 8 px } }
< / style >