|
|
|
|
@ -0,0 +1,49 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
import { Message, Modal } from '@arco-design/web-vue'
|
|
|
|
|
import { http } from '../../api/http'
|
|
|
|
|
import { acknowledgeCameraAlert, createCameraDevice, deleteCameraDevice, fetchCameraAlerts, fetchCameraDevices, fetchCameraHealth, fetchCameraSettings, retryCameraDevice, updateCameraDevice, updateCameraSettings, type CameraAlert, type CameraDevice, type CameraSettings } from '../../api/cameraMonitoring'
|
|
|
|
|
|
|
|
|
|
const tab = ref('health'); const loading = ref(false); const devices = ref<CameraDevice[]>([]); const alerts = ref<CameraAlert[]>([]); const settings = ref<CameraSettings | null>(null); const editing = ref<CameraDevice | null>(null); const formVisible = ref(false); const form = ref<Record<string, any>>({ monitor_enabled: true, notification_enabled: true, include_in_venue_stats: true, sdk_port: 8000, http_port: 80, channel: 1, push_timeout_seconds: 300 })
|
|
|
|
|
const venues = ref<Array<{ id: number; name: string }>>([]); const filters = ref<{ venue_id?: number; status?: string; keyword: string }>({ keyword: '' })
|
|
|
|
|
const healthFilter = ref<'all' | 'normal' | 'abnormal'>('all')
|
|
|
|
|
const summary = ref<Record<string, number>>({})
|
|
|
|
|
const recipientText = ref('')
|
|
|
|
|
const statusLabel = (s?: string) => ({ ONLINE_PUSHING: '推送正常', PUSH_SUSPECT: '疑似停止', PUSH_STALE: '推送停止', NETWORK_DOWN: '网络断开', LOGIN_FAILED: '登录失败', ARM_FAILED: '布防失败', UNKNOWN: '未知', DISABLED: '已停用', RECONNECTING: '重连中' }[String(s || 'UNKNOWN')] || String(s || '未知'))
|
|
|
|
|
const statusColor = (s?: string) => ['ONLINE_PUSHING'].includes(String(s)) ? 'green' : ['PUSH_SUSPECT','RECONNECTING'].includes(String(s)) ? 'orangered' : 'red'
|
|
|
|
|
const formatTime = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN') : '—'
|
|
|
|
|
const healthRows = computed(() => devices.value.filter((row) => {
|
|
|
|
|
const venueMatch = !filters.value.venue_id || Number(row.venue_id) === Number(filters.value.venue_id)
|
|
|
|
|
const status = String(row.health?.status || 'UNKNOWN')
|
|
|
|
|
const statusMatch = healthFilter.value === 'normal' ? status === 'ONLINE_PUSHING' : healthFilter.value === 'abnormal' ? ['PUSH_STALE', 'NETWORK_DOWN', 'LOGIN_FAILED', 'ARM_FAILED', 'PUSH_SUSPECT'].includes(status) : (!filters.value.status || status === filters.value.status)
|
|
|
|
|
const keyword = filters.value.keyword.trim().toLowerCase()
|
|
|
|
|
const keywordMatch = !keyword || `${row.name} ${row.ip} ${row.venue_name || row.venue?.name || ''}`.toLowerCase().includes(keyword)
|
|
|
|
|
return venueMatch && statusMatch && keywordMatch
|
|
|
|
|
}))
|
|
|
|
|
async function loadVenues() { try { const { data } = await http.get('/venues'); venues.value = Array.isArray(data) ? data : [] } catch { venues.value = [] } }
|
|
|
|
|
async function load() { loading.value = true; try { const [h, a, d, s] = await Promise.all([fetchCameraHealth(), fetchCameraAlerts({ status: 'OPEN', page_size: 100 }), fetchCameraDevices({ page_size: 100 }), fetchCameraSettings()]); devices.value = h.devices ?? d.data ?? []; summary.value = h.summary?.byStatus ?? {}; alerts.value = a.data ?? []; settings.value = { ...s, notification_start_time: String(s.notification_start_time || '').slice(0, 5), notification_end_time: String(s.notification_end_time || '').slice(0, 5) }; recipientText.value = (s.recipient_emails || []).join(',') } catch (e: any) { Message.error(e?.response?.data?.message || e?.message || '加载摄像头预警数据失败') } finally { loading.value = false } }
|
|
|
|
|
function openCreate() { editing.value = null; form.value = { monitor_enabled: true, notification_enabled: true, include_in_venue_stats: true, sdk_port: 8000, http_port: 80, channel: 1, push_timeout_seconds: settings.value?.default_push_timeout_seconds ?? 300 }; formVisible.value = true }
|
|
|
|
|
function openEdit(row: CameraDevice) { editing.value = row; form.value = { ...row, password: '' }; formVisible.value = true }
|
|
|
|
|
async function save() { try { if (editing.value) await updateCameraDevice(editing.value.id, form.value); else await createCameraDevice(form.value); Message.success('已保存'); formVisible.value = false; await load() } catch (e: any) { Message.error(e?.response?.data?.message || e?.message || '保存失败') } }
|
|
|
|
|
async function retry(row: CameraDevice) { Modal.confirm({ title: '确认重新连接', content: '请确认摄像头已正常在线或已经重启,再重新链接。', okText: '确认重新连接', cancelText: '关闭', onOk: async () => { try { await retryCameraDevice(row.id); Message.success('重试指令已下发,等待新的客流推送验证'); await load() } catch (e: any) { Message.error(e?.response?.data?.message || e?.message || '重试失败') } } }) }
|
|
|
|
|
async function remove(row: CameraDevice) { Modal.confirm({ title: '停用摄像头', content: `确定停用 ${row.name}(${row.ip})吗?`, okText: '确认停用', cancelText: '关闭', onOk: async () => { await deleteCameraDevice(row.id); Message.success('已停用'); await load() } }) }
|
|
|
|
|
async function ack(row: CameraAlert) { await acknowledgeCameraAlert(row.id); Message.success('已确认'); await load() }
|
|
|
|
|
async function saveSettings() { if (!settings.value) return; settings.value.recipient_emails = recipientText.value.split(',').map(x => x.trim()).filter(Boolean); await updateCameraSettings(settings.value); Message.success('通知设置已保存') }
|
|
|
|
|
onMounted(() => { void Promise.all([load(), loadVenues()]) })
|
|
|
|
|
</script>
|
|
|
|
|
<template>
|
|
|
|
|
<a-card title="场馆管理 / 摄像头预警">
|
|
|
|
|
<a-tabs v-model:active-key="tab">
|
|
|
|
|
<a-tab-pane key="health" title="实时状态">
|
|
|
|
|
<a-space wrap :size="12" style="margin-bottom: 12px"><a-tag color="green" class="clickable-tag" @click="healthFilter = 'normal'; filters.status = undefined">正常 {{ summary.ONLINE_PUSHING || 0 }}</a-tag><a-tag color="red" class="clickable-tag" @click="healthFilter = 'abnormal'; filters.status = undefined">异常 {{ (summary.PUSH_STALE || 0) + (summary.NETWORK_DOWN || 0) + (summary.LOGIN_FAILED || 0) + (summary.ARM_FAILED || 0) }}</a-tag><a-tag class="clickable-tag" @click="healthFilter = 'all'; filters.status = undefined">总数 {{ devices.length }}</a-tag><a-input v-model="filters.keyword" allow-clear placeholder="搜索摄像头名称或 IP" style="width: 240px" /><a-select v-model="filters.venue_id" allow-clear allow-search placeholder="按场馆筛选" style="width: 220px"><a-option v-for="venue in venues" :key="venue.id" :value="venue.id">{{ venue.name }}</a-option></a-select><a-select v-model="filters.status" allow-clear allow-search placeholder="按状态筛选" style="width: 160px" @change="healthFilter = 'all'"><a-option value="ONLINE_PUSHING">推送正常</a-option><a-option value="PUSH_STALE">推送停止</a-option><a-option value="NETWORK_DOWN">网络断开</a-option><a-option value="LOGIN_FAILED">登录失败</a-option><a-option value="ARM_FAILED">布防失败</a-option><a-option value="UNKNOWN">未知</a-option></a-select><a-button @click="load" :loading="loading">刷新</a-button><a-button type="primary" @click="openCreate">新增摄像头</a-button></a-space>
|
|
|
|
|
<a-table :data="healthRows" :loading="loading" row-key="id" :pagination="false">
|
|
|
|
|
<template #columns><a-table-column title="场馆"><template #cell="{ record }">{{ record.venue?.name || record.venue_name || '未绑定' }}</template></a-table-column><a-table-column title="摄像头"><template #cell="{ record }">{{ record.name }}<div class="muted">{{ record.ip }} / 通道 {{ record.channel }}</div></template></a-table-column><a-table-column title="状态"><template #cell="{ record }"><a-tag :color="statusColor(record.health?.status)">{{ statusLabel(record.health?.status) }}</a-tag></template></a-table-column><a-table-column title="最后推送"><template #cell="{ record }">{{ formatTime(record.health?.last_pdc_at) }}</template></a-table-column><a-table-column title="网络连通性"><template #cell="{ record }">{{ record.health?.tcp_reachable === true ? '网络正常' : record.health?.tcp_reachable === false ? '网络不可达' : '未检测' }}</template></a-table-column><a-table-column title="操作"><template #cell="{ record }"><a-space><a-button size="small" @click="openEdit(record)">编辑</a-button><a-button size="small" type="primary" @click="retry(record)">重新连接</a-button><a-button size="small" status="danger" @click="remove(record)">停用</a-button></a-space></template></a-table-column></template>
|
|
|
|
|
</a-table>
|
|
|
|
|
</a-tab-pane>
|
|
|
|
|
<a-tab-pane key="alerts" title="待处理告警"><a-table :data="alerts" :pagination="false"><template #columns><a-table-column title="时间" data-index="first_occurred_at"/><a-table-column title="场馆"><template #cell="{ record }">{{ record.venue?.name || '—' }}</template></a-table-column><a-table-column title="设备"><template #cell="{ record }">{{ record.camera?.name }}({{ record.camera?.ip }})</template></a-table-column><a-table-column title="原因" data-index="reason"/><a-table-column title="操作"><template #cell="{ record }"><a-button size="small" @click="ack(record)">确认处理</a-button></template></a-table-column></template></a-table></a-tab-pane>
|
|
|
|
|
<a-tab-pane key="settings" title="通知设置"><a-form v-if="settings" :model="settings" layout="vertical" style="max-width: 680px"><a-form-item label="接收邮箱"><a-input v-model="recipientText" placeholder="多个邮箱用逗号分隔" /></a-form-item><a-form-item label="通知开始时间"><a-time-picker v-model="settings.notification_start_time" format="HH:mm" /></a-form-item><a-form-item label="通知结束时间"><a-time-picker v-model="settings.notification_end_time" format="HH:mm" /></a-form-item><a-form-item label="客流推送超时时间"><a-input-number v-model="settings.default_push_timeout_seconds" :min="60" :max="86400" /><div class="form-help">超过这个时间没有收到该摄像头的客流数据,系统会判定为推送异常,并继续进行自动重连判断。单位:秒。</div></a-form-item><a-form-item><a-button type="primary" @click="saveSettings">保存设置</a-button></a-form-item></a-form></a-tab-pane>
|
|
|
|
|
</a-tabs>
|
|
|
|
|
</a-card>
|
|
|
|
|
<a-modal v-model:visible="formVisible" :title="editing ? '编辑摄像头' : '新增摄像头'" :height="640" :body-style="{ maxHeight: '520px', overflowY: 'auto' }" @ok="save"><a-form :model="form" layout="vertical"><a-form-item label="名称"><a-input v-model="form.name" /></a-form-item><a-form-item label="所属场馆"><a-select v-model="form.venue_id" allow-clear allow-search placeholder="请输入或选择场馆"><a-option v-for="venue in venues" :key="venue.id" :value="venue.id">{{ venue.name }}</a-option></a-select></a-form-item><a-form-item label="IP/域名"><a-input v-model="form.ip" /></a-form-item><a-form-item label="SDK 端口"><a-input-number v-model="form.sdk_port" /></a-form-item><a-form-item label="通道"><a-input-number v-model="form.channel" :min="1" /></a-form-item><a-form-item label="设备槽位"><a-input-number v-model="form.device_slot" /></a-form-item><a-form-item label="用户名"><a-input v-model="form.username" /></a-form-item><a-form-item label="密码"><a-input-password v-model="form.password" placeholder="编辑时留空表示不修改" /></a-form-item><a-form-item label="客流推送超时时间"><a-input-number v-model="form.push_timeout_seconds" :min="60" /><div class="form-help">该摄像头超过设定时间没有客流数据时,系统会判定推送异常。单位:秒。</div></a-form-item><a-form-item label="监测"><a-switch v-model="form.monitor_enabled" /></a-form-item><a-form-item label="发送人工介入邮件"><a-switch v-model="form.notification_enabled" /></a-form-item></a-form></a-modal>
|
|
|
|
|
</template>
|
|
|
|
|
<style scoped>.muted{color:var(--color-text-3);font-size:12px}.form-help{color:var(--color-text-3);font-size:12px;line-height:1.5;margin-top:4px}.clickable-tag{cursor:pointer}</style>
|