master
lion 4 days ago
parent 8fd34cadbf
commit 5f6b1a134a

@ -4,8 +4,8 @@ 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; last_pdc_at_source?: 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 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; acknowledged_at?: string | null; camera?: Pick<CameraDevice,'id'|'name'|'ip'|'channel'>; venue?: { id: number; name: string } }
export type CameraSettings = { repeat_notification_enabled: boolean; repeat_notification_interval_hours: number; 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 }
@ -16,3 +16,9 @@ export async function fetchCameraAlerts(params?: Record<string, unknown>) { cons
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 }
export type CameraEmailRecord = { id: number; receiver: string; send_status: string; sent_at?: string | null; created_at: string; error_message?: string | null; alert?: { title: string; camera?: { name: string; ip: string; channel: number; venue_name?: string | null }; venue?: { name: string } } }
export async function fetchCameraEmailRecords(params: { page: number; page_size: number; send_status?: string }) {
const { data } = await http.get<{ data: CameraEmailRecord[]; total: number }>('/camera-email-records', { params })
return data
}

@ -1,10 +1,49 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } 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'
import { fetchCameraEmailRecords, type CameraEmailRecord, 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 alertPage = ref(1)
const alertTotal = ref(0)
const alertStatus = ref<string>()
const alertLoading = ref(false)
let alertRequest = 0
async function loadAlerts(page = alertPage.value) {
alertPage.value = page
const request = ++alertRequest
alertLoading.value = true
try {
const data = await fetchCameraAlerts({ page, page_size: 20, status: alertStatus.value })
if (request !== alertRequest) return
alerts.value = data.data ?? []
alertTotal.value = data.total ?? 0
} catch (e: any) {
if (request === alertRequest) Message.error(e?.response?.data?.message || '加载告警记录失败')
} finally { if (request === alertRequest) alertLoading.value = false }
}
watch(tab, value => { if (value === 'alerts') void loadAlerts() })
const emailRecords = ref<CameraEmailRecord[]>([])
const emailLoading = ref(false)
const emailPage = ref(1)
const emailTotal = ref(0)
const emailStatus = ref<string>()
let emailRequest = 0
async function loadEmailRecords(page = emailPage.value) {
emailPage.value = page
const request = ++emailRequest
emailLoading.value = true
try {
const data = await fetchCameraEmailRecords({ page, page_size: 20, send_status: emailStatus.value })
if (request !== emailRequest) return
emailRecords.value = data.data
emailTotal.value = data.total
} catch (e: any) {
if (request === emailRequest) Message.error(e?.response?.data?.message || '加载邮件发送记录失败')
} finally { if (request === emailRequest) emailLoading.value = false }
}
watch(tab, value => { if (value === 'emails') void loadEmailRecords() })
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>>({})
@ -26,16 +65,16 @@ const healthRows = computed(() => devices.value.filter((row) => {
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 } }
async function load() { loading.value = true; try { const [h, d, s] = await Promise.all([fetchCameraHealth(), fetchCameraDevices({ page_size: 100 }), fetchCameraSettings()]); devices.value = h.devices ?? d.data ?? []; summary.value = h.summary?.byStatus ?? {}; settings.value = { ...s, repeat_notification_enabled: s.repeat_notification_enabled ?? false, repeat_notification_interval_hours: s.repeat_notification_interval_hours ?? 24, 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) { if (acknowledging.value[row.id]) return; acknowledging.value[row.id] = true; try { await acknowledgeCameraAlert(row.id); Message.success('已确认'); await load() } catch (e: any) { const status = e?.response?.status; Message.error(status === 429 ? '操作过于频繁,请稍后再试' : e?.response?.data?.message || e?.message || '确认处理失败') } finally { acknowledging.value[row.id] = false } }
async function ack(row: CameraAlert) { if (acknowledging.value[row.id]) return; acknowledging.value[row.id] = true; try { await acknowledgeCameraAlert(row.id); Message.success('已确认'); await loadAlerts() } catch (e: any) { const status = e?.response?.status; Message.error(status === 429 ? '操作过于频繁,请稍后再试' : e?.response?.data?.message || e?.message || '确认处理失败') } finally { acknowledging.value[row.id] = false } }
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) })
onMounted(() => { void Promise.all([load(), loadVenues()]); refreshTimer = setInterval(() => { if (tab.value === 'alerts') { if (!alertLoading.value) void loadAlerts(); return } if (tab.value === 'emails') { if (!emailLoading.value) void loadEmailRecords(); return } if (tab.value !== 'settings' && !loading.value && !Object.values(retrying.value).some(Boolean)) void load() }, 60000) })
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
</script>
<template>
@ -47,8 +86,48 @@ onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
<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" :loading="acknowledging[record.id]" :disabled="acknowledging[record.id]" @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-tab-pane key="alerts" title="处理告警">
<a-space :size="12" style="margin-bottom: 16px">
<a-select v-model="alertStatus" allow-clear placeholder="全部处理状态" style="width: 200px" @change="loadAlerts(1)">
<a-option value="OPEN">待处理</a-option><a-option value="ACKNOWLEDGED">已人工确认</a-option><a-option value="RESOLVED">已自动恢复</a-option>
</a-select>
<a-button :loading="alertLoading" @click="loadAlerts()">刷新</a-button>
</a-space>
<a-table :data="alerts" :loading="alertLoading" row-key="id" :scroll="{ x: 1450 }"
:pagination="{ current: alertPage, pageSize: 20, total: alertTotal, showTotal: true }" @page-change="loadAlerts">
<template #columns>
<a-table-column title="告警时间" :width="190"><template #cell="{ record }">{{ formatTime(record.first_occurred_at) }}</template></a-table-column>
<a-table-column title="场馆" :width="190"><template #cell="{ record }">{{ record.venue?.name || record.camera?.venue_name || '—' }}</template></a-table-column>
<a-table-column title="设备" :width="220"><template #cell="{ record }">{{ record.camera?.name || '—' }}<div v-if="record.camera" class="muted">{{ record.camera.ip }} / 通道 {{ record.camera.channel }}</div></template></a-table-column>
<a-table-column title="原因" data-index="reason" :width="300" />
<a-table-column title="处理状态" :width="140"><template #cell="{ record }"><a-tag :color="record.status === 'OPEN' ? 'red' : record.status === 'RESOLVED' ? 'green' : 'blue'">{{ ({ OPEN: '待处理', ACKNOWLEDGED: '已人工确认', RESOLVED: '已自动恢复' } as Record<string, string>)[record.status] || record.status }}</a-tag></template></a-table-column>
<a-table-column title="处理时间" :width="220"><template #cell="{ record }">{{ formatTime(record.status === 'RESOLVED' ? record.recovered_at : record.acknowledged_at) }}<div v-if="record.status === 'RESOLVED' && record.acknowledged_at" class="muted">人工确认:{{ formatTime(record.acknowledged_at) }}</div></template></a-table-column>
<a-table-column title="操作" :width="140"><template #cell="{ record }"><a-button v-if="record.status === 'OPEN'" size="small" :loading="acknowledging[record.id]" :disabled="acknowledging[record.id]" @click="ack(record)">确认处理</a-button><span v-else>—</span></template></a-table-column>
</template>
</a-table>
<div class="form-help">默认保留并显示全部告警记录。已人工确认不代表设备恢复;恢复推送后自动标记为已自动恢复,并保留原人工确认时间。时间按北京时间显示。</div>
</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-switch v-model="settings.repeat_notification_enabled" /><div class="form-help">开启后,摄像头持续异常且仍需人工处理时,按设定间隔再次发送邮件。确认处理不会停止提醒,恢复推送后自动停止。</div></a-form-item><a-form-item v-if="settings.repeat_notification_enabled" label="重复通知间隔(小时)"><a-input-number v-model="settings.repeat_notification_interval_hours" :min="1" :max="8760" :precision="0" /><div class="form-help">默认 24 小时,从上次成功发送邮件开始计算;到期不在通知时间段内,将顺延至允许发送时段。不会发送恢复通知。</div></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-tab-pane key="emails" title="邮件发送记录">
<a-space :size="12" style="margin-bottom: 16px">
<a-select v-model="emailStatus" allow-clear placeholder="全部发送结果" style="width: 180px" @change="loadEmailRecords(1)">
<a-option value="sent">发送成功</a-option><a-option value="failed">发送失败</a-option><a-option value="pending">待发送</a-option>
</a-select>
<a-button :loading="emailLoading" @click="loadEmailRecords()">刷新</a-button>
</a-space>
<a-table :data="emailRecords" :loading="emailLoading" row-key="id" :scroll="{ x: 1300 }"
:pagination="{ current: emailPage, pageSize: 20, total: emailTotal, showTotal: true }" @page-change="loadEmailRecords">
<template #columns>
<a-table-column title="发送时间" :width="190"><template #cell="{ record }">{{ formatTime(record.sent_at || record.created_at) }}</template></a-table-column>
<a-table-column title="场馆" :width="200"><template #cell="{ record }">{{ record.alert?.venue?.name || record.alert?.camera?.venue_name || '—' }}</template></a-table-column>
<a-table-column title="摄像头" :width="230"><template #cell="{ record }">{{ record.alert?.camera?.name || '—' }}<div v-if="record.alert?.camera" class="muted">{{ record.alert.camera.ip }} / 通道 {{ record.alert.camera.channel }}</div></template></a-table-column>
<a-table-column title="收件邮箱" data-index="receiver" :width="230" />
<a-table-column title="发送结果" :width="120"><template #cell="{ record }"><a-tag :color="record.send_status === 'sent' ? 'green' : record.send_status === 'failed' ? 'red' : 'gray'">{{ ({ sent: '发送成功', failed: '发送失败', pending: '待发送' } as Record<string, string>)[record.send_status] || record.send_status }}</a-tag></template></a-table-column>
<a-table-column title="失败原因" :width="330"><template #cell="{ record }"><div style="white-space: normal; overflow-wrap: anywhere">{{ record.error_message || '—' }}</div></template></a-table-column>
</template>
</a-table>
<div class="form-help">时间按北京时间显示;发送失败时显示尝试时间。发送成功表示邮件服务器已接受发送,不代表收件人已阅读。首次和重复发送均会记录。</div>
</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>

Loading…
Cancel
Save