You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

56 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { computed, onMounted, onUnmounted, 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 retrying = ref<Record<number, boolean>>({})
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: '布防失败', NO_PUSH_YET: '尚未收到推送', SERVICE_UNAVAILABLE: '监测服务不可用', 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) => { if (!v) return '—'; const raw = String(v).replace(/\.(\d{3})\d+/g, '.$1'); const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw.replace(' ', 'T')}Z`; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) }
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', 'NO_PUSH_YET', 'SERVICE_UNAVAILABLE'].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
}).sort((a, b) => {
const rank = (status?: string) => ({ SERVICE_UNAVAILABLE: 0, LOGIN_FAILED: 1, ARM_FAILED: 2, NETWORK_DOWN: 3, PUSH_STALE: 4, PUSH_SUSPECT: 5, NO_PUSH_YET: 6, UNKNOWN: 7, RECONNECTING: 8, ONLINE_PUSHING: 9 }[String(status || 'UNKNOWN')] ?? 7)
return rank(a.health?.status) - rank(b.health?.status)
}))
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) { if (retrying.value[row.id]) return; Modal.confirm({ title: '确认重新连接', content: '请确认摄像头已正常在线或已经重启,再重新链接。', okText: '确认重新连接', cancelText: '关闭', onOk: async () => { retrying.value[row.id] = true; try { await retryCameraDevice(row.id); Message.success('重试指令已下发,正在等待登录、布防和客流推送验证'); await new Promise((resolve) => setTimeout(resolve, 5000)); await load(); retrying.value[row.id] = false } catch (e: any) { retrying.value[row.id] = false; 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('通知设置已保存') }
let refreshTimer: ReturnType<typeof setInterval> | undefined
onMounted(() => { void Promise.all([load(), loadVenues()]); refreshTimer = setInterval(() => { if (!loading.value && !Object.values(retrying.value).some(Boolean)) void load() }, 60000) })
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
</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) + (summary.NO_PUSH_YET || 0) + (summary.SERVICE_UNAVAILABLE || 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="NO_PUSH_YET">尚未收到推送</a-option><a-option value="SERVICE_UNAVAILABLE">监测服务不可用</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="最后推送" :width="190"><template #cell="{ record }">{{ formatTime(record.health?.last_pdc_at_source || record.health?.last_pdc_at) }}</template></a-table-column><a-table-column title="网络连通性" :width="140"><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" :loading="retrying[record.id]" :disabled="retrying[record.id]" @click="retry(record)">{{ retrying[record.id] ? '重新连接中' : '重新连接' }}</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="时间"><template #cell="{ record }">{{ formatTime(record.first_occurred_at) }}</template></a-table-column><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>