feat: 实现 Sense 本地事件只读查看 (#73)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listLocalEvents(query) {
|
||||
return request({ url: '/api/v1/local-events', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getLocalEvent(id) {
|
||||
return request({ url: `/api/v1/local-events/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>本地事件候选</h3>
|
||||
<p>查看 Sense 在本机形成的匿名事件候选及证据处理状态。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadEvents">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
|
||||
<template #title>本页不依赖 Bell</template>
|
||||
这里展示的是 Sense 本地事件候选,不代表已生成或已送达 Bell 预警;Bell 未安装或未启动时仍可正常查看。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="当前页事件概览">
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页候选</span><strong>{{ pageSummary.candidate }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页已确认</span><strong>{{ pageSummary.confirmed }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页证据失败</span><strong>{{ pageSummary.failed }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form :model="query" label-width="78px" class="filter-form" @submit.prevent="search">
|
||||
<el-form-item label="发生时间" class="time-field">
|
||||
<el-date-picker v-model="occurredRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" :clearable="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="候选状态">
|
||||
<el-select v-model="query.candidateState" clearable placeholder="全部">
|
||||
<el-option label="候选" value="candidate" /><el-option label="已确认事件" value="confirmed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="证据状态">
|
||||
<el-select v-model="query.evidenceState" clearable placeholder="全部">
|
||||
<el-option label="处理中" value="pending" /><el-option label="成功" value="success" /><el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则来源"><el-input v-model="query.ruleRef" clearable placeholder="规则编号" /></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="events" border stripe empty-text="没有符合条件的本地事件候选">
|
||||
<el-table-column prop="occurredAt" label="发生时间" width="176">
|
||||
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="事件编号" min-width="205" show-overflow-tooltip />
|
||||
<el-table-column label="摄像头" min-width="145">
|
||||
<template #default="scope"><div>{{ scope.row.sourceRef }}</div><small>{{ scope.row.sourceLabel || '未命名位置' }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规则来源" min-width="160">
|
||||
<template #default="scope"><div>{{ scope.row.ruleName }}</div><small>{{ scope.row.ruleRef }} {{ scope.row.ruleVersion }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="候选状态" width="118" align="center">
|
||||
<template #default="scope"><el-tag :type="candidateType(scope.row.candidateState)">{{ candidateLabel(scope.row.candidateState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证据状态" width="104" align="center">
|
||||
<template #default="scope"><el-tag :type="evidenceType(scope.row.evidenceState)">{{ evidenceLabel(scope.row.evidenceState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保留期限" width="142">
|
||||
<template #default="scope"><span :class="{ 'retention-warning': scope.row.retentionState === 'expiring' }">{{ retentionLabel(scope.row) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="94" fixed="right">
|
||||
<template #default="scope"><el-button v-permisaction="['sense:local-event:detail']" type="primary" link @click="openDetail(scope.row.id)">查看详情</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="loadEvents" />
|
||||
<p class="retention-help">保留策略由 Sense 服务端配置;列表显示每条记录的实际到期时间,到期清理策略不由本页面修改。列表和详情读取均受权限控制并进入系统操作审计。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="本地事件详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<el-descriptions v-if="selected" :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="摄像头">{{ selected.sourceRef }} / {{ selected.sourceLabel || '未命名位置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规则来源">{{ selected.ruleName }} {{ selected.ruleVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域引用">{{ selected.zoneRef || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="保留期限">{{ retentionLabel(selected) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="候选状态"><el-tag :type="candidateType(selected.candidateState)">{{ candidateLabel(selected.candidateState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="证据状态"><el-tag :type="evidenceType(selected.evidenceState)">{{ evidenceLabel(selected.evidenceState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="Bell 送达状态" :span="2"><strong>不适用</strong> — 本地候选未发送到 Bell,本页不推断 Bell Alert 状态。</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="selected" :title="evidenceTitle" :type="evidenceType(selected.evidenceState)" :closable="false" show-icon class="evidence-alert">
|
||||
{{ selected.evidenceDetail || '暂无更多证据处理说明' }}<template v-if="selected.evidenceRef"><br>本地证据引用:{{ selected.evidenceRef }}</template>
|
||||
</el-alert>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
|
||||
import { buildLocalEventQuery, candidateLabel, candidateType, evidenceLabel, evidenceType, retentionLabel } from './localEventState'
|
||||
|
||||
defineOptions({ name: 'SenseLocalEvent' })
|
||||
|
||||
const loading = ref(false)
|
||||
const events = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const now = Date.now()
|
||||
const occurredRange = ref([new Date(now - 24 * 60 * 60 * 1000), new Date(now)])
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, candidateState: '', evidenceState: '', ruleRef: '', keyword: '' })
|
||||
const pageSummary = computed(() => events.value.reduce((value, item) => {
|
||||
if (item.candidateState === 'candidate') value.candidate += 1
|
||||
if (item.candidateState === 'confirmed') value.confirmed += 1
|
||||
if (item.evidenceState === 'failed') value.failed += 1
|
||||
return value
|
||||
}, { candidate: 0, confirmed: 0, failed: 0 }))
|
||||
const evidenceTitle = computed(() => `证据处理:${evidenceLabel(selected.value?.evidenceState)}`)
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
|
||||
async function loadEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listLocalEvents(buildLocalEventQuery(query, occurredRange.value)))
|
||||
events.value = payload?.list || payload?.data || []
|
||||
total.value = payload?.count || 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件查询失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() { query.pageIndex = 1; loadEvents() }
|
||||
function reset() {
|
||||
query.pageIndex = 1; query.candidateState = ''; query.evidenceState = ''; query.ruleRef = ''; query.keyword = ''
|
||||
const current = Date.now(); occurredRange.value = [new Date(current - 24 * 60 * 60 * 1000), new Date(current)]
|
||||
loadEvents()
|
||||
}
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getLocalEvent(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件详情查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:16px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:68px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:24px}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin-bottom:2px}.filter-form .el-form-item{width:210px}.filter-form .time-field{width:420px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select),.filter-form :deep(.el-date-editor){width:100%}small{color:#909399}.retention-warning{color:#e6a23c}.retention-help{margin:12px 0 0;color:#909399;font-size:12px}.evidence-alert{margin-top:16px}@media (max-width:768px){.filter-form .el-form-item,.filter-form .time-field{width:100%}.detail-descriptions :deep(.el-descriptions__body .el-descriptions__table){display:block;overflow-x:auto}}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
export const candidateLabels = { candidate: '候选', confirmed: '已确认事件' }
|
||||
export const evidenceLabels = { pending: '处理中', success: '成功', failed: '失败' }
|
||||
|
||||
export function candidateLabel(value) {
|
||||
return candidateLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function candidateType(value) {
|
||||
return value === 'confirmed' ? 'success' : 'primary'
|
||||
}
|
||||
|
||||
export function evidenceLabel(value) {
|
||||
return evidenceLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function evidenceType(value) {
|
||||
return ({ pending: 'warning', success: 'success', failed: 'danger' })[value] || 'info'
|
||||
}
|
||||
|
||||
export function buildLocalEventQuery(query, occurredRange) {
|
||||
const result = {
|
||||
pageIndex: query.pageIndex,
|
||||
pageSize: query.pageSize,
|
||||
candidateState: query.candidateState || undefined,
|
||||
evidenceState: query.evidenceState || undefined,
|
||||
ruleRef: String(query.ruleRef || '').trim() || undefined,
|
||||
keyword: String(query.keyword || '').trim() || undefined
|
||||
}
|
||||
if (occurredRange?.length === 2) {
|
||||
result.occurredAfter = new Date(occurredRange[0]).toISOString()
|
||||
result.occurredBefore = new Date(occurredRange[1]).toISOString()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function retentionLabel(item) {
|
||||
if (!item?.retainUntil) return '-'
|
||||
if (item.retentionState === 'expired') return '已到期'
|
||||
if (item.retentionState === 'expiring') return `${item.remainingDays} 天内到期`
|
||||
return `保留至 ${String(item.retainUntil).slice(0, 10)}`
|
||||
}
|
||||
Reference in New Issue
Block a user