|
|
<script setup lang="ts">
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
import { Message, Modal } from '@arco-design/web-vue'
|
|
|
import {
|
|
|
fetchHikDeviceLinks,
|
|
|
fetchHikHealth,
|
|
|
fetchHikPeopleCounting,
|
|
|
fetchHikVenuesHourly,
|
|
|
formatIncludedGroups,
|
|
|
type HikDeviceLinkRow,
|
|
|
healthStatusColor,
|
|
|
netInVenue,
|
|
|
postHikDeviceLinksRetry,
|
|
|
type HikDeviceLinksResponse,
|
|
|
type HikPeopleCountingGroup,
|
|
|
type HikPeopleCountingVenue,
|
|
|
type HikPeopleCountingResponse,
|
|
|
type HikVenueRangeTotalRow,
|
|
|
type HikHealthResponse,
|
|
|
type HikVenueHourlyRow,
|
|
|
type HikVenuesHourlyResponse,
|
|
|
} from '../../api/hikPdcClient'
|
|
|
|
|
|
const loadingPc = ref(false)
|
|
|
const loadingHealth = ref(false)
|
|
|
|
|
|
function todayYmd(): string {
|
|
|
const d = new Date()
|
|
|
const p = (n: number) => String(n).padStart(2, '0')
|
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
|
}
|
|
|
|
|
|
const activeTab = ref('realtime')
|
|
|
const peopleCountingDate = ref(todayYmd())
|
|
|
/** 对应请求参数 end_date;留空则仅按起始日单日查询 */
|
|
|
const peopleCountingEndDate = ref('')
|
|
|
|
|
|
const peopleData = ref<HikPeopleCountingResponse | null>(null)
|
|
|
const healthData = ref<HikHealthResponse | null>(null)
|
|
|
const healthError = ref<string | null>(null)
|
|
|
const lastFetchAt = ref<string | null>(null)
|
|
|
|
|
|
const venues = computed(() => peopleData.value?.venues ?? [])
|
|
|
|
|
|
/** 是否展示区间内各馆累计(服务端返回 range / venuesRangeTotals 等时) */
|
|
|
const peopleRangeSectionVisible = computed(() => {
|
|
|
const p = peopleData.value
|
|
|
if (!p || p.code !== 200) return false
|
|
|
const totals = p.venuesRangeTotals
|
|
|
const hasTotals = Array.isArray(totals) && totals.length > 0
|
|
|
const hasMeta =
|
|
|
!!(p.rangeFrom || p.rangeTo || (p.venuesRangeTotalsNote || '').trim() || (p.venuesRangeMissingDates?.length ?? 0))
|
|
|
return hasTotals || hasMeta
|
|
|
})
|
|
|
|
|
|
const hourlyDate = ref(todayYmd())
|
|
|
const hourlyVenueId = ref<string | undefined>(undefined)
|
|
|
const hourlyData = ref<HikVenuesHourlyResponse | null>(null)
|
|
|
const loadingHourly = ref(false)
|
|
|
|
|
|
const deviceProbe = ref(false)
|
|
|
const deviceLinksData = ref<HikDeviceLinksResponse | null>(null)
|
|
|
const loadingDevices = ref(false)
|
|
|
const retrySubmitting = ref(false)
|
|
|
const selectedRetrySlots = ref<number[]>([])
|
|
|
|
|
|
const nestedModalVisible = ref(false)
|
|
|
const nestedModalVenue = ref<HikPeopleCountingVenue | null>(null)
|
|
|
|
|
|
const hourlyVenueFilterOptions = computed(() =>
|
|
|
venues.value.map((v) => ({
|
|
|
label: `${v.venueName || v.venueId} (${v.venueId})`,
|
|
|
value: String(v.venueId),
|
|
|
})),
|
|
|
)
|
|
|
|
|
|
/** 按小时页场馆下拉:支持按名称或场馆 ID 关键字筛选 */
|
|
|
function filterHourlyVenueOption(inputValue: string, option: { label?: string; value?: string }) {
|
|
|
const q = (inputValue || '').trim().toLowerCase()
|
|
|
if (!q) return true
|
|
|
const label = String(option?.label ?? '').toLowerCase()
|
|
|
const value = String(option?.value ?? '').toLowerCase()
|
|
|
return label.includes(q) || value.includes(q)
|
|
|
}
|
|
|
|
|
|
async function refreshPeopleCounting(showToast = false) {
|
|
|
const d = (peopleCountingDate.value || '').trim()
|
|
|
const ed = (peopleCountingEndDate.value || '').trim()
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
|
|
Message.warning('请选择合法的统计起始日期(yyyy-MM-dd)')
|
|
|
return
|
|
|
}
|
|
|
if (ed && !/^\d{4}-\d{2}-\d{2}$/.test(ed)) {
|
|
|
Message.warning('请选择合法的统计结束日期(yyyy-MM-dd)')
|
|
|
return
|
|
|
}
|
|
|
if (ed && ed < d) {
|
|
|
Message.warning('统计结束日期须大于或等于起始日期')
|
|
|
return
|
|
|
}
|
|
|
loadingPc.value = true
|
|
|
try {
|
|
|
peopleData.value = await fetchHikPeopleCounting(d, ed || undefined)
|
|
|
lastFetchAt.value = new Date().toLocaleString('zh-CN')
|
|
|
if (peopleData.value.code !== 200) {
|
|
|
Message.warning(peopleData.value.message || `请求失败(错误码 ${peopleData.value.code})`)
|
|
|
} else if (showToast) {
|
|
|
Message.success('客流数据已更新')
|
|
|
}
|
|
|
} catch (e: unknown) {
|
|
|
peopleData.value = null
|
|
|
const ax = e as { response?: { status?: number; data?: { message?: string } | string } }
|
|
|
const st = ax.response?.status
|
|
|
const raw = ax.response?.data
|
|
|
const rawObj =
|
|
|
typeof raw === 'object' && raw != null ? (raw as Record<string, unknown>) : null
|
|
|
const bodyMsg = rawObj
|
|
|
? String(rawObj.message ?? rawObj.error ?? rawObj.hint ?? '').trim()
|
|
|
: ''
|
|
|
const msg =
|
|
|
e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e)
|
|
|
if (st === 404) {
|
|
|
Message.warning(bodyMsg || '暂无该日期的归档数据')
|
|
|
} else if (st === 400) {
|
|
|
Message.warning(bodyMsg || '请求参数无效(请检查日期区间)')
|
|
|
} else {
|
|
|
Message.error(msg || '客流接口请求失败')
|
|
|
}
|
|
|
} finally {
|
|
|
loadingPc.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function refreshHealth() {
|
|
|
loadingHealth.value = true
|
|
|
healthError.value = null
|
|
|
try {
|
|
|
healthData.value = await fetchHikHealth()
|
|
|
} catch (e: unknown) {
|
|
|
healthData.value = null
|
|
|
healthError.value =
|
|
|
e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e)
|
|
|
Message.error(healthError.value || '健康检查失败')
|
|
|
} finally {
|
|
|
loadingHealth.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function refreshAll() {
|
|
|
await Promise.all([refreshPeopleCounting(true), refreshHealth()])
|
|
|
}
|
|
|
|
|
|
async function queryHourly() {
|
|
|
const d = (hourlyDate.value || '').trim()
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
|
|
Message.warning('请选择合法日期 yyyy-MM-dd')
|
|
|
return
|
|
|
}
|
|
|
loadingHourly.value = true
|
|
|
hourlyData.value = null
|
|
|
try {
|
|
|
hourlyData.value = await fetchHikVenuesHourly(d, hourlyVenueId.value || undefined)
|
|
|
Message.success('已加载按小时数据')
|
|
|
} catch (e: unknown) {
|
|
|
const msg =
|
|
|
e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e)
|
|
|
Message.error(msg || '按小时查询失败')
|
|
|
} finally {
|
|
|
loadingHourly.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadDeviceLinks() {
|
|
|
loadingDevices.value = true
|
|
|
deviceLinksData.value = null
|
|
|
selectedRetrySlots.value = []
|
|
|
try {
|
|
|
deviceLinksData.value = await fetchHikDeviceLinks({
|
|
|
probe: deviceProbe.value,
|
|
|
live: deviceProbe.value,
|
|
|
})
|
|
|
Message.success(deviceProbe.value ? '已加载设备(含探测)' : '已加载设备链路')
|
|
|
const failedSlots = [...new Set((deviceLinksData.value.devices ?? [])
|
|
|
.filter((x) => deviceLinkNotOnline(x.status))
|
|
|
.map((x) => x.deviceSlot)
|
|
|
.filter((n): n is number => typeof n === 'number' && Number.isFinite(n)))].sort((a, b) => a - b)
|
|
|
selectedRetrySlots.value = failedSlots
|
|
|
} catch (e: unknown) {
|
|
|
const ax = e as { response?: { status?: number; data?: { message?: string } } }
|
|
|
const st = ax.response?.status
|
|
|
const msg =
|
|
|
ax.response?.data?.message ||
|
|
|
(e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e))
|
|
|
Message.error(st === 403 ? '探测已被服务端关闭' : msg || '设备链路加载失败')
|
|
|
} finally {
|
|
|
loadingDevices.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function deviceLinkNotOnline(status: string | undefined): boolean {
|
|
|
return String(status || '').trim().toUpperCase() !== 'ONLINE'
|
|
|
}
|
|
|
|
|
|
/** 重试结果/下拉项:用配置中的设备名(多即场馆名)+ IP 展示 */
|
|
|
function formatDeviceVenueIpLabel(deviceSlot: number): string {
|
|
|
const devs = deviceLinksData.value?.devices ?? []
|
|
|
const row = devs.find((d) => d.deviceSlot === deviceSlot)
|
|
|
const venueName = (row?.name || '').trim() || `设备槽位 ${deviceSlot}`
|
|
|
const ip = (row?.ip || '').trim()
|
|
|
return ip ? `${venueName} ${ip}` : venueName
|
|
|
}
|
|
|
|
|
|
function retryOptions() {
|
|
|
const devs = deviceLinksData.value?.devices ?? []
|
|
|
const slots = [...new Set(devs.map((d) => d.deviceSlot).filter((n): n is number => typeof n === 'number'))]
|
|
|
return slots.sort((a, b) => a - b).map((s) => ({
|
|
|
label: formatDeviceVenueIpLabel(s),
|
|
|
value: s,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
async function confirmRetryLogin() {
|
|
|
const indices = selectedRetrySlots.value.filter((n) => Number.isFinite(n))
|
|
|
if (!indices.length) {
|
|
|
Message.warning('请至少选择一个设备槽位序号')
|
|
|
return
|
|
|
}
|
|
|
retrySubmitting.value = true
|
|
|
try {
|
|
|
const res = await postHikDeviceLinksRetry({ deviceIndices: indices })
|
|
|
if (res.code === 200) {
|
|
|
Message.success(res.message || '重试指令已下发')
|
|
|
const lines = (res.results ?? []).map((r) => {
|
|
|
const head = formatDeviceVenueIpLabel(r.deviceIndex)
|
|
|
return `${head}:${r.ok ? '成功' : '失败'} ${r.message || ''}`.trim()
|
|
|
})
|
|
|
if (lines.length) {
|
|
|
Modal.info({
|
|
|
title: '重试结果',
|
|
|
content: lines.join('\n'),
|
|
|
})
|
|
|
}
|
|
|
await loadDeviceLinks()
|
|
|
await refreshHealth()
|
|
|
} else {
|
|
|
Message.warning(res.message || `错误码 ${res.code}`)
|
|
|
}
|
|
|
} catch (e: unknown) {
|
|
|
const ax = e as { response?: { status?: number } }
|
|
|
const msg =
|
|
|
e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e)
|
|
|
Message.error(ax.response?.status === 403 ? '服务端已禁止远程重试登录' : msg || '重试请求失败')
|
|
|
} finally {
|
|
|
retrySubmitting.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function openVenueNestedModal(v: HikPeopleCountingVenue) {
|
|
|
nestedModalVenue.value = v
|
|
|
nestedModalVisible.value = true
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
void refreshAll()
|
|
|
})
|
|
|
|
|
|
const venueRangeTotalsColumns = [
|
|
|
{ title: '场馆ID', dataIndex: 'venueId', width: 120, ellipsis: true, tooltip: true },
|
|
|
{ title: '场馆名称', dataIndex: 'venueName', width: 180, ellipsis: true, tooltip: true },
|
|
|
{ title: '入馆总人数', dataIndex: 'enter', width: 130 },
|
|
|
{ title: '离开(区间累计)', dataIndex: 'exit', width: 125 },
|
|
|
{ title: '经过(区间累计)', dataIndex: 'passing', width: 125 },
|
|
|
]
|
|
|
|
|
|
function venueRangeTotalsRows(): HikVenueRangeTotalRow[] {
|
|
|
const list = peopleData.value?.venuesRangeTotals
|
|
|
return Array.isArray(list) ? list : []
|
|
|
}
|
|
|
|
|
|
/** Arco Table summary:区间累计数值列合计(合计行固定在表格底部,见 scroll.y) */
|
|
|
function venueRangeTotalsSummary(ctx: { data: HikVenueRangeTotalRow[] }) {
|
|
|
const rows = Array.isArray(ctx.data) ? ctx.data : []
|
|
|
let enter = 0
|
|
|
let exit = 0
|
|
|
let passing = 0
|
|
|
for (const r of rows) {
|
|
|
enter += Number(r.enter) || 0
|
|
|
exit += Number(r.exit) || 0
|
|
|
passing += Number(r.passing) || 0
|
|
|
}
|
|
|
return [
|
|
|
{
|
|
|
venueId: '合计',
|
|
|
venueName: '—',
|
|
|
enter,
|
|
|
exit,
|
|
|
passing,
|
|
|
},
|
|
|
]
|
|
|
}
|
|
|
|
|
|
/** 各场馆当日表:数值列合计;在馆(推算) 为汇总进入/离开的推算值(与各馆在馆之和一致) */
|
|
|
function venuesDailySummary(ctx: { data: HikPeopleCountingVenue[] }) {
|
|
|
const rows = Array.isArray(ctx.data) ? ctx.data : []
|
|
|
let enter = 0
|
|
|
let exit = 0
|
|
|
let passing = 0
|
|
|
let updateCount = 0
|
|
|
for (const r of rows) {
|
|
|
enter += Number(r.enter) || 0
|
|
|
exit += Number(r.exit) || 0
|
|
|
passing += Number(r.passing) || 0
|
|
|
updateCount += Number(r.updateCount) || 0
|
|
|
}
|
|
|
return [
|
|
|
{
|
|
|
venueId: '合计',
|
|
|
venueName: '—',
|
|
|
enter,
|
|
|
exit,
|
|
|
passing,
|
|
|
updateCount,
|
|
|
},
|
|
|
]
|
|
|
}
|
|
|
|
|
|
function missingRangeDatesText(): string {
|
|
|
const arr = peopleData.value?.venuesRangeMissingDates
|
|
|
if (!Array.isArray(arr) || !arr.length) return ''
|
|
|
return arr.filter(Boolean).join('、')
|
|
|
}
|
|
|
|
|
|
const venueColumns = [
|
|
|
{ title: '场馆ID', dataIndex: 'venueId', width: 140, ellipsis: true, tooltip: true },
|
|
|
{ title: '场馆名称', dataIndex: 'venueName', width: 180, ellipsis: true, tooltip: true },
|
|
|
{ title: '进入', dataIndex: 'enter', width: 96 },
|
|
|
{ title: '离开', dataIndex: 'exit', width: 96 },
|
|
|
{ title: '经过', dataIndex: 'passing', width: 96 },
|
|
|
{ title: '在馆(推算)', slotName: 'net', width: 110 },
|
|
|
{ title: '更新次数', dataIndex: 'updateCount', width: 110 },
|
|
|
{ title: '最后更新', slotName: 'venueLastUpdate', width: 190, ellipsis: true, tooltip: true },
|
|
|
{ title: '包含组', slotName: 'included', width: 220, ellipsis: true, tooltip: true },
|
|
|
{ title: '馆内子组', slotName: 'nested', width: 100 },
|
|
|
]
|
|
|
|
|
|
const hourlyDetailColumns = [
|
|
|
{ title: '小时', dataIndex: 'hourLabel', width: 100 },
|
|
|
{ title: '进入', dataIndex: 'enter', width: 90 },
|
|
|
{ title: '离开', dataIndex: 'exit', width: 90 },
|
|
|
{ title: '经过', dataIndex: 'passing', width: 90 },
|
|
|
]
|
|
|
|
|
|
function hourlyRowsForVenue(row: HikVenueHourlyRow) {
|
|
|
const h = row.hourly ?? []
|
|
|
return h.map((b) => ({
|
|
|
key: `${row.venueId}-${b.hour}`,
|
|
|
hourLabel: b.hourLabel != null && String(b.hourLabel).trim() ? b.hourLabel : String(b.hour),
|
|
|
enter: b.enter,
|
|
|
exit: b.exit,
|
|
|
passing: b.passing,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
const deviceColumns = [
|
|
|
{ title: '槽位', dataIndex: 'deviceSlot', width: 72 },
|
|
|
{ title: '名称', dataIndex: 'name', width: 120, ellipsis: true, tooltip: true },
|
|
|
{ title: 'IP', dataIndex: 'ip', width: 130 },
|
|
|
{ title: '端口', dataIndex: 'port', width: 80 },
|
|
|
{ title: '状态', slotName: 'devStatus', width: 120 },
|
|
|
{ title: '说明', dataIndex: 'statusHint', ellipsis: true, tooltip: true },
|
|
|
{ title: '探测', slotName: 'probe', width: 200, ellipsis: true, tooltip: true },
|
|
|
]
|
|
|
|
|
|
function probeSummary(row: HikDeviceLinkRow): string {
|
|
|
const p = row.networkProbe
|
|
|
if (!p) return '—'
|
|
|
const parts = [
|
|
|
p.tcpReachable !== undefined ? (p.tcpReachable ? 'TCP 已连通' : 'TCP 未连通') : '',
|
|
|
p.tcpConnectLatencyMs != null ? `延迟 ${p.tcpConnectLatencyMs} ms` : '',
|
|
|
p.icmpReachable !== undefined ? (p.icmpReachable ? 'ICMP 已通' : 'ICMP 不可用') : '',
|
|
|
(p.summaryHint || '').trim(),
|
|
|
].filter(Boolean)
|
|
|
return parts.length ? parts.join(',') : '—'
|
|
|
}
|
|
|
|
|
|
function healthServiceStatusText(status: string | undefined): string {
|
|
|
const u = String(status || '').toUpperCase()
|
|
|
if (u === 'UP') return '正常'
|
|
|
if (u === 'DEGRADED') return '降级'
|
|
|
return (status || '').trim() || '—'
|
|
|
}
|
|
|
|
|
|
/** 表格展示:YYYY-MM-DD HH:mm:ss(本地时区) */
|
|
|
function formatZhYmdHms(ts: string | number | undefined | null): string {
|
|
|
if (ts == null || ts === '') return '—'
|
|
|
let d: Date
|
|
|
if (typeof ts === 'number') {
|
|
|
d = new Date(ts)
|
|
|
} else {
|
|
|
const s = String(ts).trim()
|
|
|
if (/^\d+$/.test(s)) {
|
|
|
const n = Number(s)
|
|
|
const ms = n > 1e11 ? n : n * 1000
|
|
|
d = new Date(ms)
|
|
|
} else {
|
|
|
d = new Date(s)
|
|
|
}
|
|
|
}
|
|
|
if (Number.isNaN(d.getTime())) return String(ts)
|
|
|
const p = (n: number) => String(n).padStart(2, '0')
|
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
|
}
|
|
|
|
|
|
function formatServiceTimestamp(ts: string | number | undefined): string {
|
|
|
if (ts == null || ts === '') return ''
|
|
|
if (typeof ts === 'number') {
|
|
|
const d = new Date(ts)
|
|
|
return Number.isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
|
|
}
|
|
|
const s = String(ts).trim()
|
|
|
if (/^\d+$/.test(s)) {
|
|
|
const n = Number(s)
|
|
|
const ms = n > 1e11 ? n : n * 1000
|
|
|
const d = new Date(ms)
|
|
|
return Number.isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
|
|
|
}
|
|
|
const d2 = new Date(s)
|
|
|
return Number.isNaN(d2.getTime()) ? s : d2.toLocaleString('zh-CN')
|
|
|
}
|
|
|
|
|
|
function deviceLinkStatusText(s: string | undefined): string {
|
|
|
const u = String(s || '').trim().toUpperCase()
|
|
|
const m: Record<string, string> = {
|
|
|
ONLINE: '在线',
|
|
|
LOGIN_FAILED: '登录失败',
|
|
|
ARM_FAILED: '布防失败',
|
|
|
}
|
|
|
return m[u] || (s || '').trim() || '—'
|
|
|
}
|
|
|
|
|
|
const nestedSubgroupColumns = [
|
|
|
{ title: '统计组ID', dataIndex: 'groupId', ellipsis: true, tooltip: true },
|
|
|
{ title: '名称', dataIndex: 'groupName', ellipsis: true, tooltip: true },
|
|
|
{ title: '进入', dataIndex: 'enter', width: 80 },
|
|
|
{ title: '离开', dataIndex: 'exit', width: 80 },
|
|
|
{ title: '经过', dataIndex: 'passing', width: 80 },
|
|
|
]
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<a-card title="客流监控 / 海康客流接口">
|
|
|
<a-tabs v-model:active-key="activeTab" type="rounded">
|
|
|
<a-tab-pane key="realtime" title="实时汇总">
|
|
|
<a-space wrap style="margin-bottom: 16px">
|
|
|
<span style="color: var(--color-text-2)">统计起始日</span>
|
|
|
<a-date-picker v-model="peopleCountingDate" value-format="YYYY-MM-DD" style="width: 160px" />
|
|
|
<span style="color: var(--color-text-2)">统计结束日</span>
|
|
|
<a-date-picker
|
|
|
v-model="peopleCountingEndDate"
|
|
|
value-format="YYYY-MM-DD"
|
|
|
allow-clear
|
|
|
style="width: 160px"
|
|
|
placeholder="可选,闭区间结束日"
|
|
|
/>
|
|
|
<span class="muted">留空则仅查询起始日单日(不传 end_date)</span>
|
|
|
<a-button type="primary" :loading="loadingPc" @click="refreshPeopleCounting(true)">拉取客流</a-button>
|
|
|
<a-button :loading="loadingHealth" @click="refreshHealth">健康检查</a-button>
|
|
|
<a-button @click="refreshAll">全部刷新</a-button>
|
|
|
<span v-if="lastFetchAt" class="muted">上次拉取:{{ lastFetchAt }}</span>
|
|
|
</a-space>
|
|
|
|
|
|
<a-card v-if="healthData || healthError" title="服务健康状态" style="margin-bottom: 16px" size="small">
|
|
|
<template v-if="healthData">
|
|
|
<a-space direction="vertical" fill>
|
|
|
<a-space wrap>
|
|
|
<a-tag :color="healthStatusColor(healthData.status)">{{
|
|
|
healthServiceStatusText(healthData.status)
|
|
|
}}</a-tag>
|
|
|
<span class="muted">配置设备路数:{{ healthData.nvrConfigured ?? '—' }}</span>
|
|
|
<span class="muted">已成功登录路数:{{ healthData.nvrLoggedIn ?? '—' }}</span>
|
|
|
<span class="muted">设备数量:{{ healthData.deviceCount ?? '—' }}</span>
|
|
|
<span class="muted">统计组条目数:{{ healthData.groupCount ?? '—' }}</span>
|
|
|
<span class="muted" v-if="healthData.timestamp">{{
|
|
|
`返回时间:${formatServiceTimestamp(healthData.timestamp)}`
|
|
|
}}</span>
|
|
|
</a-space>
|
|
|
<a-typography-text v-if="healthData.hint" type="warning">{{ healthData.hint }}</a-typography-text>
|
|
|
</a-space>
|
|
|
</template>
|
|
|
<a-typography-text v-else type="danger">{{ healthError }}</a-typography-text>
|
|
|
</a-card>
|
|
|
|
|
|
<a-card v-if="peopleData && peopleData.code === 200" title="当日客流汇总" style="margin-bottom: 16px" size="small">
|
|
|
<a-row :gutter="16">
|
|
|
<a-col :span="6">
|
|
|
<div class="sum-label">数据日期</div>
|
|
|
<div class="sum-value">{{ peopleData.date ?? '—' }}</div>
|
|
|
</a-col>
|
|
|
<a-col :span="6">
|
|
|
<a-statistic title="进入(合计)" :value="peopleData.total.enter" />
|
|
|
</a-col>
|
|
|
<a-col :span="6">
|
|
|
<a-statistic title="离开(合计)" :value="peopleData.total.exit" />
|
|
|
</a-col>
|
|
|
<a-col :span="6">
|
|
|
<a-statistic title="经过(合计)" :value="peopleData.total.passing" />
|
|
|
</a-col>
|
|
|
</a-row>
|
|
|
<div style="margin-top: 8px; color: var(--color-text-3); font-size: 12px">
|
|
|
累计接收数据条数:{{ peopleData.total.dataCount }}
|
|
|
</div>
|
|
|
<div
|
|
|
v-if="peopleData.rangeFrom || peopleData.rangeTo"
|
|
|
style="margin-top: 8px; color: var(--color-text-3); font-size: 12px"
|
|
|
>
|
|
|
查询区间:{{ peopleData.rangeFrom ?? '—' }} ~ {{ peopleData.rangeTo ?? '—' }}(快照日为「数据日期」,区间内累计见下方表格)
|
|
|
</div>
|
|
|
</a-card>
|
|
|
|
|
|
<a-card
|
|
|
v-if="peopleData && peopleData.code === 200 && peopleRangeSectionVisible"
|
|
|
title="区间内各馆累计人数"
|
|
|
style="margin-bottom: 16px"
|
|
|
size="small"
|
|
|
>
|
|
|
<a-typography-text v-if="peopleData.venuesRangeTotalsNote" type="warning" style="display: block; margin-bottom: 8px">
|
|
|
{{ peopleData.venuesRangeTotalsNote }}
|
|
|
</a-typography-text>
|
|
|
<div v-if="missingRangeDatesText()" class="muted" style="margin-bottom: 10px; font-size: 13px">
|
|
|
区间内缺失归档的日期:{{ missingRangeDatesText() }}
|
|
|
</div>
|
|
|
<a-table
|
|
|
v-if="venueRangeTotalsRows().length"
|
|
|
row-key="venueId"
|
|
|
:columns="venueRangeTotalsColumns"
|
|
|
:data="venueRangeTotalsRows()"
|
|
|
:pagination="false"
|
|
|
size="small"
|
|
|
:scroll="{ x: 800, y: 200 }"
|
|
|
:summary="venueRangeTotalsSummary"
|
|
|
/>
|
|
|
<div v-else class="muted" style="font-size: 13px">暂无区间内各馆累计数据</div>
|
|
|
</a-card>
|
|
|
|
|
|
<a-empty v-if="peopleData === null && !loadingPc" description="暂无数据,请先拉取客流" />
|
|
|
|
|
|
<a-alert
|
|
|
v-else-if="peopleData && peopleData.code !== 200"
|
|
|
type="warning"
|
|
|
style="margin-bottom: 16px"
|
|
|
>
|
|
|
接口返回非 200:{{ peopleData.message || peopleData.code }}
|
|
|
</a-alert>
|
|
|
|
|
|
<a-card title="各场馆当日人数统计" style="margin-bottom: 16px" size="small">
|
|
|
<a-table
|
|
|
row-key="venueId"
|
|
|
:columns="venueColumns"
|
|
|
:data="venues"
|
|
|
:pagination="false"
|
|
|
size="small"
|
|
|
:scroll="{ x: 1300, y: 280 }"
|
|
|
:summary="venuesDailySummary"
|
|
|
>
|
|
|
<template #net="{ record }">{{ netInVenue(record) }}</template>
|
|
|
<template #venueLastUpdate="{ record }">{{ formatZhYmdHms(record.lastUpdate) }}</template>
|
|
|
<template #included="{ record }">{{ formatIncludedGroups(record.includedGroups) }}</template>
|
|
|
<template #nested="{ record }">
|
|
|
<a-button
|
|
|
v-if="(record.groups?.length ?? 0) > 0"
|
|
|
type="text"
|
|
|
size="mini"
|
|
|
@click="openVenueNestedModal(record)"
|
|
|
>
|
|
|
{{ record.groups!.length }} 组
|
|
|
</a-button>
|
|
|
<span v-else>—</span>
|
|
|
</template>
|
|
|
<template #summary-cell="{ column, record }">
|
|
|
<template v-if="column.dataIndex === 'venueId'">合计</template>
|
|
|
<template v-else-if="column.dataIndex === 'venueName'">—</template>
|
|
|
<template v-else-if="column.dataIndex === 'enter'">{{ record.enter }}</template>
|
|
|
<template v-else-if="column.dataIndex === 'exit'">{{ record.exit }}</template>
|
|
|
<template v-else-if="column.dataIndex === 'passing'">{{ record.passing }}</template>
|
|
|
<template v-else-if="column.slotName === 'net'">{{ netInVenue(record) }}</template>
|
|
|
<template v-else-if="column.dataIndex === 'updateCount'">{{ record.updateCount }}</template>
|
|
|
<template v-else-if="column.slotName === 'venueLastUpdate'">—</template>
|
|
|
<template v-else-if="column.slotName === 'included'">—</template>
|
|
|
<template v-else-if="column.slotName === 'nested'">—</template>
|
|
|
<template v-else>—</template>
|
|
|
</template>
|
|
|
</a-table>
|
|
|
<div v-if="venues.length === 0" style="margin-top: 8px; color: var(--color-text-3); font-size: 12px">
|
|
|
暂无场馆数据时,请检查服务端场馆与统计组映射是否已正确配置。
|
|
|
</div>
|
|
|
</a-card>
|
|
|
</a-tab-pane>
|
|
|
|
|
|
<a-tab-pane key="hourly" title="场馆按小时">
|
|
|
<a-space wrap style="margin-bottom: 12px">
|
|
|
<a-date-picker v-model="hourlyDate" value-format="YYYY-MM-DD" style="width: 160px" />
|
|
|
<a-select
|
|
|
v-model="hourlyVenueId"
|
|
|
placeholder="可选:搜索并筛选单个场馆"
|
|
|
allow-clear
|
|
|
allow-search
|
|
|
:filter-option="filterHourlyVenueOption"
|
|
|
style="width: 280px"
|
|
|
:options="hourlyVenueFilterOptions"
|
|
|
/>
|
|
|
<a-button type="primary" :loading="loadingHourly" @click="queryHourly">按小时查询</a-button>
|
|
|
</a-space>
|
|
|
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
|
|
|
须选择查询日期;可按需指定场馆。数据来自服务内存按小时汇总,过早历史日期可能均为 0。
|
|
|
</a-typography-text>
|
|
|
|
|
|
<template v-if="hourlyData?.venues?.length">
|
|
|
<div v-for="vr in hourlyData.venues || []" :key="vr.venueId" style="margin-bottom: 16px">
|
|
|
<a-card size="small" :title="(vr.venueName || vr.venueId) + `(${vr.venueId})`">
|
|
|
<a-table
|
|
|
:columns="hourlyDetailColumns"
|
|
|
:data="hourlyRowsForVenue(vr)"
|
|
|
row-key="key"
|
|
|
:pagination="false"
|
|
|
size="small"
|
|
|
/>
|
|
|
</a-card>
|
|
|
</div>
|
|
|
</template>
|
|
|
<a-empty v-else-if="!loadingHourly" description="请选择日期后查询" />
|
|
|
</a-tab-pane>
|
|
|
|
|
|
<a-tab-pane key="devices" title="设备链路 / 重试">
|
|
|
<a-space wrap style="margin-bottom: 12px">
|
|
|
<a-switch v-model="deviceProbe">
|
|
|
<template #checked>含网络探测(较慢)</template>
|
|
|
<template #unchecked>仅快照</template>
|
|
|
</a-switch>
|
|
|
<a-button :loading="loadingDevices" @click="loadDeviceLinks">加载设备链路</a-button>
|
|
|
</a-space>
|
|
|
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
|
|
|
开启网络探测会向各设备端口发起连通性检测,耗时更长;服务端可关闭此项(将返回无权访问)。
|
|
|
</a-typography-text>
|
|
|
|
|
|
<a-card v-if="deviceLinksData" title="设备列表" size="small" style="margin-bottom: 16px">
|
|
|
<div v-if="deviceLinksData.probedAt" class="muted" style="margin-bottom: 8px">
|
|
|
探测完成时间:{{ formatServiceTimestamp(deviceLinksData.probedAt) }}
|
|
|
</div>
|
|
|
<div v-if="deviceLinksData.probeDetail" class="muted" style="margin-bottom: 8px">
|
|
|
{{ deviceLinksData.probeDetail }}
|
|
|
</div>
|
|
|
<a-table
|
|
|
row-key="__k"
|
|
|
:columns="deviceColumns"
|
|
|
:data="
|
|
|
(deviceLinksData.devices || []).map((d, i) => ({
|
|
|
...d,
|
|
|
__k: `${d.deviceSlot}-${d.ip}-${i}`,
|
|
|
}))
|
|
|
"
|
|
|
:pagination="false"
|
|
|
size="small"
|
|
|
:scroll="{ x: 1000, y: 220 }"
|
|
|
>
|
|
|
<template #devStatus="{ record }">{{ deviceLinkStatusText(record.status) }}</template>
|
|
|
<template #probe="{ record }">{{ probeSummary(record) }}</template>
|
|
|
</a-table>
|
|
|
</a-card>
|
|
|
|
|
|
<a-card title="设备重试登录" size="small">
|
|
|
<a-space direction="vertical" fill>
|
|
|
<a-select
|
|
|
v-model="selectedRetrySlots"
|
|
|
:options="retryOptions()"
|
|
|
multiple
|
|
|
placeholder="默认为失败设备;可选场馆名称与 IP 对应项"
|
|
|
allow-clear
|
|
|
style="width: 100%; max-width: 720px"
|
|
|
/>
|
|
|
<a-space wrap>
|
|
|
<a-button type="primary" status="danger" :loading="retrySubmitting" @click="confirmRetryLogin">
|
|
|
发起重试登录
|
|
|
</a-button>
|
|
|
<span class="muted">向服务下发重试指令,不勾选从磁盘重新加载配置(如有需要可由运维在服务端操作)</span>
|
|
|
</a-space>
|
|
|
</a-space>
|
|
|
</a-card>
|
|
|
</a-tab-pane>
|
|
|
</a-tabs>
|
|
|
|
|
|
<a-modal v-model:visible="nestedModalVisible" title="馆内分组明细" :footer="false" width="800px">
|
|
|
<template v-if="nestedModalVenue">
|
|
|
<a-typography-text type="secondary" style="margin-bottom: 8px; display: block">
|
|
|
{{ nestedModalVenue.venueName }} / {{ nestedModalVenue.venueId }}
|
|
|
</a-typography-text>
|
|
|
<a-table
|
|
|
row-key="groupId"
|
|
|
:columns="nestedSubgroupColumns"
|
|
|
:data="(nestedModalVenue.groups || []) as HikPeopleCountingGroup[]"
|
|
|
:pagination="false"
|
|
|
size="small"
|
|
|
/>
|
|
|
</template>
|
|
|
</a-modal>
|
|
|
</a-card>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.muted {
|
|
|
color: var(--color-text-3);
|
|
|
font-size: 13px;
|
|
|
}
|
|
|
|
|
|
code {
|
|
|
padding: 0 4px;
|
|
|
background: var(--color-fill-2);
|
|
|
border-radius: 2px;
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
|
|
|
.sum-label {
|
|
|
color: var(--color-text-2);
|
|
|
font-size: 12px;
|
|
|
margin-bottom: 4px;
|
|
|
}
|
|
|
|
|
|
.sum-value {
|
|
|
font-size: 20px;
|
|
|
font-weight: 500;
|
|
|
}
|
|
|
|
|
|
</style>
|