@@ -0,0 +1,65 @@
< template >
< BasicLayout >
< template # wrapper >
< el-card class = "box-card" >
< div class = "page-header" >
< div > < h3 > 媒体分片 < / h3 > < p > 查看 MediaMTX 分片容量 、 路径归属与故障影响 。 故障不会自动迁移路径 。 < / p > < / div >
< el-button :icon = "Refresh" :loading = "loading" @click ="load" > 刷新状态 < / el -button >
< / div >
< el-alert v-if = "summary.failed" type="error" :closable="false" show-icon class="status-alert" >
< template # title > { { summary . failed } } 个媒体分片异常 , 影响 { { summary . impactedPaths } } 条路径 < / template >
路径仍保持原分片归属 , 便于定位故障范围 ; 请先查看详情 , 不要直接变更生产配置 。
< / el-alert >
< el-row :gutter = "12" class = "summary-row" aria -label = " 媒体分片概览 " >
< el-col v-for = "item in summaryCards" :key="item.label" :xs="12" :sm="6"><div class="summary-item"><span>{{ item.label }}</span><strong :class="item.className" > {{ item.value }} < / strong > < / div > < / el -col >
< / el-row >
< el-table v-loading = "loading" :data="shards" border stripe empty-text="暂无媒体分片运行记录" >
< el -table -column label = "分片" min -width = " 180 " > < template # default = "scope" > < strong > { { scope . row . name } } < / strong > < div class = "muted" > { { scope . row . id } } · { { stateLabel ( scope . row . mode ) } } < / div > < / template > < / el-table-column >
< el-table-column label = "状态" width = "112" align = "center" > < template # default = "scope" > < el-tag :type = "stateType(scope.row.status)" > { { stateLabel ( scope . row . status ) } } < / el-tag > < div class = "muted" > { { freshnessText ( scope . row ) } } < / div > < / template > < / el-table-column >
< el-table-column label = "已分配 / 容量" min -width = " 190 " > < template # default = "scope" > < div > { { scope . row . assignedPaths } } / { { scope . row . capacity } } 条路径 < / div > < el-progress :percentage = "usagePercent(scope.row)" : status = "usagePercent(scope.row) >= 90 ? 'warning' : ''" / > < / template > < / el-table-column >
< el-table-column label = "剩余容量" width = "110" align = "center" > < template # default = "scope" > { { scope . row . remaining } } < / template > < / el-table-column >
< el-table-column prop = "detail" label = "运行说明" min -width = " 210 " show -overflow -tooltip / >
< el-table-column label = "最近探测" min -width = " 170 " > < template # default = "scope" > { { formatTime ( scope . row . lastProbeAt ) } } < / template > < / el-table-column >
< el-table-column label = "操作" width = "156" fixed = "right" > < template # default = "scope" > < el-button v-permisaction = "['sense:media-shard:detail']" type="primary" link @click="openDetail(scope.row.id)">影响详情</el-button><el-button v-permisaction="['sense:media-shard:preflight']" type="primary" link @click="openPreflight(scope.row.id)" > 迁移预检 < / el -button > < / template > < / el-table-column >
< / el-table >
< / el-card >
< el-dialog v-model = "detailOpen" title="分片故障影响" width="min(880px, calc(100vw - 24px))" :close-on-click-modal="false" >
< template v-if = "selected"><el-descriptions :column="2" border><el-descriptions-item label="分片">{{ selected.name }}( {{ selected.id }}) </el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="stateType(selected.status)">{{ stateLabel(selected.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="路径占用">{{ selected.assignedPaths }} / {{ selected.capacity }}</el-descriptions-item><el-descriptions-item label="运行说明" > {{ selected.detail }} < / el -descriptions -item > < / el-descriptions >
< h4 > 受影响设备与 Profile < / h4 > < el-table : data = "selected.impact || []" border empty -text = " 此分片暂无路径归属 " > < el-table-column prop = "deviceName" label = "设备" min -width = " 150 " > < template # default = "scope" > { { scope . row . deviceName || scope . row . deviceId } } < div class = "muted" > { { scope . row . location || '未填写位置' } } < / div > < / template > < / el-table-column > < el-table-column prop = "profileToken" label = "Profile" min -width = " 130 " / > < el-table-column prop = "path" label = "媒体路径" min -width = " 180 " / > < el-table-column prop = "actual" label = "最近状态" width = "120" / > < / el-table >
< / template > < template # footer > < el-button @click ="detailOpen = false" > 关闭 < / el -button > < / template >
< / el-dialog >
< el-dialog v-model = "preflightOpen" title="跨分片迁移预检(只读)" width="min(720px, calc(100vw - 24px))" :close-on-click-modal="false" >
< template v-if = "preflight"><el-alert :type="preflight.ready ? 'warning' : 'error'" :title="preflightConclusion(preflight)" :closable="false" show-icon /><el-descriptions :column="2" border class="preflight-summary"><el-descriptions-item label="源分片">{{ preflight.sourceShardId }}</el-descriptions-item><el-descriptions-item label="候选目标">{{ preflight.targetShardName || '无可用目标' }}</el-descriptions-item><el-descriptions-item label="影响路径">{{ preflight.impactedPaths }}</el-descriptions-item><el-descriptions-item label="执行授权">未授权</el-descriptions-item></el-descriptions><el-table :data="preflight.checks || []" border><el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '未通过' }}</el-tag></template></el-table-column><el-table-column prop="name" label="检查项" width="130" /><el-table-column prop="detail" label="说明" min-width="260" / > < / el-table > < / template >
< template # footer > < span class = "read-only-note" > 本页面不会修改任何路径归属 。 < / span > < el-button @click ="preflightOpen = false" > 关闭 < / el -button > < / template >
< / el-dialog >
< / template >
< / BasicLayout >
< / template >
< script setup >
import { computed , onMounted , reactive , ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getMediaShard , listMediaShards , preflightMediaShard } from '@/api/sense/media-shard'
import { freshnessText , preflightConclusion , stateLabel , stateType , usagePercent } from './mediaShardState'
defineOptions ( { name : 'SenseMediaShard' } )
const loading = ref ( false ) ; const shards = ref ( [ ] ) ; const selected = ref ( null ) ; const preflight = ref ( null ) ; const detailOpen = ref ( false ) ; const preflightOpen = ref ( false )
const summary = reactive ( { total : 0 , available : 0 , failed : 0 , configuredCapacity : 0 , assignedPaths : 0 , impactedPaths : 0 } )
const summaryCards = computed ( ( ) => [ { label : '分片总数' , value : summary . total } , { label : '可用 / 异常' , value : ` ${ summary . available } / ${ summary . failed } ` , className : summary . failed ? 'danger-number' : 'success-number' } , { label : '已分配 / 总容量' , value : ` ${ summary . assignedPaths } / ${ summary . configuredCapacity } ` } , { label : '故障影响' , value : ` ${ summary . impactedPaths } 条路径 ` , className : summary . impactedPaths ? 'danger-number' : '' } ] )
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 listMediaShards ( ) ) || { } ; shards . value = payload . list || [ ] ; Object . assign ( summary , payload . summary || { } ) } catch ( error ) { ElMessage . error ( error . message || '媒体分片加载失败' ) } finally { loading . value = false } }
async function openDetail ( id ) { try { selected . value = unwrap ( await getMediaShard ( id ) ) ; detailOpen . value = true } catch ( error ) { ElMessage . error ( error . message || '分片详情加载失败' ) } }
async function openPreflight ( id ) { try { preflight . value = unwrap ( await preflightMediaShard ( id ) ) ; preflightOpen . value = true } catch ( error ) { ElMessage . error ( error . message || '迁移预检失败' ) } }
onMounted ( load )
< / script >
< style scoped >
. page - header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px } . page - header h3 { margin : 0 0 6 px } . page - header p { margin : 0 ; color : # 909399 } . status - alert { margin : 16 px 0 } . summary - row { margin : 16 px 0 20 px } . summary - item { display : flex ; align - items : center ; justify - content : space - between ; min - height : 72 px ; padding : 12 px 16 px ; border : 1 px solid # ebeef5 ; border - radius : 4 px } . summary - item span { color : # 606266 } . summary - item strong { font - size : 20 px } . success - number { color : # 67 c23a } . danger - number { color : # f56c6c } . muted { color : # 909399 ; font - size : 12 px ; margin - top : 4 px } . preflight - summary { margin : 16 px 0 } . read - only - note { margin - right : 16 px ; color : # 909399 } @ media ( max - width : 768 px ) { . page - header { align - items : stretch ; flex - direction : column } . summary - item { margin - bottom : 8 px ; min - height : 64 px } . page - header . el - button { min - height : 44 px } }
< / style >