摄像头预警

master
lion 6 days ago
parent 3aee410270
commit 96f7c3004b

@ -0,0 +1,18 @@
import { http } from './http'
export type CameraDevice = {
id: number; venue_id?: number | null; venue_name?: string | null; name: string; ip: string; sdk_port: number; http_port?: number; channel: number; device_slot?: number | null; group_id?: string | null; monitor_enabled: boolean; notification_enabled: boolean; include_in_venue_stats: boolean; maintenance_until?: string | null; push_timeout_seconds: number; health?: CameraHealth | null; venue?: { id: number; name: string } | null
}
export type CameraHealth = { status: string; sdk_status?: string; alarm_status?: string; tcp_reachable?: boolean | null; last_pdc_at?: string | null; push_age_seconds?: number | null; last_tcp_error?: string | null; reconnect_attempts?: number; observed_at?: string | null }
export type CameraAlert = { id: number; alert_type: string; status: string; severity: string; title: string; reason?: string; first_occurred_at?: string; last_occurred_at?: string; recovered_at?: string | null; camera?: Pick<CameraDevice,'id'|'name'|'ip'|'channel'>; venue?: { id: number; name: string } }
export type CameraSettings = { timezone: string; notification_start_time: string; notification_end_time: string; default_push_interval_seconds: number; default_push_timeout_seconds: number; probe_interval_seconds: number; probe_failure_threshold: number; auto_reconnect_max_attempts: number; send_manual_action_email: boolean; send_recovery_email: boolean; recipient_emails: string[] }
export async function fetchCameraDevices(params?: Record<string, unknown>) { const { data } = await http.get('/camera-devices', { params }); return data }
export async function createCameraDevice(payload: Record<string, unknown>) { const { data } = await http.post('/camera-devices', payload); return data }
export async function updateCameraDevice(id: number, payload: Record<string, unknown>) { const { data } = await http.put(`/camera-devices/${id}`, payload); return data }
export async function deleteCameraDevice(id: number) { const { data } = await http.delete(`/camera-devices/${id}`); return data }
export async function retryCameraDevice(id: number) { const { data } = await http.post(`/camera-devices/${id}/retry`); return data }
export async function fetchCameraHealth() { const { data } = await http.get('/camera-health'); return data }
export async function fetchCameraAlerts(params?: Record<string, unknown>) { const { data } = await http.get('/camera-alerts', { params }); return data }
export async function acknowledgeCameraAlert(id: number) { const { data } = await http.post(`/camera-alerts/${id}/acknowledge`); return data }
export async function fetchCameraSettings() { const { data } = await http.get('/camera-monitor-settings'); return data }
export async function updateCameraSettings(payload: CameraSettings) { const { data } = await http.put('/camera-monitor-settings', payload); return data }

@ -2,6 +2,7 @@
export const routeLoaders: Record<string, () => Promise<{ default: unknown }>> = {
'/dashboard': () => import('../views/Dashboard.vue'),
'/venues': () => import('../views/venues/VenueList.vue'),
'/venues/camera-alerts': () => import('../views/venues/CameraAlerts.vue'),
'/activities': () => import('../views/activities/ActivityList.vue'),
'/activities/registrations': () => import('../views/activities/Registrations.vue'),
'/activities/verify': () => import('../views/activities/Verify.vue'),

@ -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>
Loading…
Cancel
Save