实时人数

master
lion 4 weeks ago
parent 1725b16a41
commit e9670d3e21

@ -0,0 +1,238 @@
import axios from 'axios'
/** 与 H5 共用:完整 GET 地址,如 `https://host:18080/api/people-counting`(端口随部署,见服务端 `api.server.port` */
export function getPeopleCountingEndpoint(): string {
const raw = (import.meta.env.VITE_PEOPLE_COUNTING_URL as string | undefined)?.trim() ?? ''
return raw.replace(/\/+$/, '')
}
export function getHikPdcApiBase(): string {
const ep = getPeopleCountingEndpoint()
if (!ep) return ''
return ep.replace(/\/api\/people-counting$/i, '')
}
export type HikPeopleCountingGroup = {
groupId: string
groupName: string
enter: number
exit: number
passing: number
updateCount?: number
lastUpdate?: string
}
/** `includedGroups` 新版为 string[],旧版可能为方括号字符串 */
export type HikPeopleCountingVenue = {
venueId: string
venueName: string
enter: number
exit: number
passing: number
updateCount?: number
lastUpdate?: string
includedGroups?: string[] | string
groups?: HikPeopleCountingGroup[]
}
export type HikPeopleCountingResponse = {
code: number
message?: string
timestamp?: number
date?: string
nvrConfigured?: number
nvrLoggedIn?: number
totalNote?: string
total: {
enter: number
exit: number
passing: number
dataCount: number
}
groups?: HikPeopleCountingGroup[]
venues?: HikPeopleCountingVenue[]
}
export type HikHealthResponse = {
status: string
timestamp?: number
/** 兼容旧字段 */
deviceCount?: number
nvrConfigured?: number
nvrLoggedIn?: number
groupCount?: number
hint?: string
}
export type HikHourlyBucket = {
hour: number
hourLabel?: string
enter: number
exit: number
passing: number
}
export type HikVenueHourlyRow = {
venueId: string
venueName?: string
hourly?: HikHourlyBucket[]
}
/** 实际结构以运行服务为准;常见为带 date + venues */
export type HikVenuesHourlyResponse = {
code?: number
message?: string
date?: string
venues?: HikVenueHourlyRow[]
}
export type HikDeviceLinkRow = {
deviceSlot?: number
name?: string
ip?: string
port?: number
status?: string
statusHint?: string
networkProbe?: {
tcpReachable?: boolean
tcpConnectLatencyMs?: number
icmpReachable?: boolean
summaryHint?: string
}
}
export type HikDeviceLinksResponse = {
devices?: HikDeviceLinkRow[]
probedAt?: string
probeDetail?: string
}
export type HikDeviceRetryResult = {
deviceIndex: number
ok: boolean
status?: string
message?: string
}
export type HikDeviceRetryResponse = {
code: number
message?: string
nvrLoggedIn?: number
results?: HikDeviceRetryResult[]
}
const EXTERNAL_TIMEOUT_MS = 20000
const PROBE_TIMEOUT_MS = 45000
function externalAxios(baseURL: string, timeout = EXTERNAL_TIMEOUT_MS) {
return axios.create({
baseURL: baseURL.replace(/\/+$/, ''),
timeout,
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
})
}
/** @param date yyyy-MM-dd传今日与内存当日汇总一致其他日期需服务端存在 `people_counting_{date}.json` 归档 */
export async function fetchHikPeopleCounting(date?: string): Promise<HikPeopleCountingResponse> {
const url = getPeopleCountingEndpoint()
if (!url) {
throw new Error('未配置 VITE_PEOPLE_COUNTING_URL海康客流 people-counting 完整地址)')
}
const base = url.replace(/\/api\/people-counting$/i, '')
const client = externalAxios(base)
const d = (date || '').trim()
const params =
d && /^\d{4}-\d{2}-\d{2}$/.test(d)
? { date: d }
: undefined
const { data } = await client.get<HikPeopleCountingResponse>('/api/people-counting', { params })
return data
}
export async function fetchHikHealth(): Promise<HikHealthResponse> {
const base = getHikPdcApiBase()
if (!base) {
throw new Error('未配置 VITE_PEOPLE_COUNTING_URL无法推导 /api/health 基址')
}
const client = externalAxios(base)
const { data } = await client.get<HikHealthResponse>('/api/health')
return data
}
/** GET /api/venues/hourly?date=yyyy-MM-dd&venueId=可选 */
export async function fetchHikVenuesHourly(date: string, venueId?: string): Promise<HikVenuesHourlyResponse> {
const base = getHikPdcApiBase()
if (!base) {
throw new Error('未配置 VITE_PEOPLE_COUNTING_URL无法请求 /api/venues/hourly')
}
const client = externalAxios(base)
const { data } = await client.get<HikVenuesHourlyResponse>('/api/venues/hourly', {
params: {
date: date.trim(),
...(venueId && String(venueId).trim() ? { venueId: String(venueId).trim() } : {}),
},
})
return data
}
/** GET /api/device-linksprobe/live 会做网络探测(可能较慢) */
export async function fetchHikDeviceLinks(options?: {
probe?: boolean
live?: boolean
tcpTimeoutMs?: number
}): Promise<HikDeviceLinksResponse> {
const base = getHikPdcApiBase()
if (!base) {
throw new Error('未配置 VITE_PEOPLE_COUNTING_URL无法请求 /api/device-links')
}
const useProbe = options?.probe || options?.live
const client = externalAxios(base, useProbe ? PROBE_TIMEOUT_MS : EXTERNAL_TIMEOUT_MS)
const params: Record<string, string | number> = {}
if (options?.probe) params.probe = 'true'
if (options?.live) params.live = '1'
if (options?.tcpTimeoutMs != null && Number.isFinite(options.tcpTimeoutMs)) {
params.tcpTimeoutMs = Math.min(30000, Math.max(300, Math.floor(options.tcpTimeoutMs)))
}
const { data } = await client.get<HikDeviceLinksResponse>('/api/device-links', { params })
return data
}
/** POST /api/device-links/retry */
export async function postHikDeviceLinksRetry(payload: {
deviceIndices: number[]
reloadConfig?: boolean
}): Promise<HikDeviceRetryResponse> {
const base = getHikPdcApiBase()
if (!base) {
throw new Error('未配置 VITE_PEOPLE_COUNTING_URL无法请求重试接口')
}
const client = externalAxios(base, EXTERNAL_TIMEOUT_MS * 2)
const { data } = await client.post<HikDeviceRetryResponse>('/api/device-links/retry', payload)
return data
}
export async function fetchHikApiRoot(): Promise<unknown> {
const base = getHikPdcApiBase()
if (!base) throw new Error('未配置 VITE_PEOPLE_COUNTING_URL')
const client = externalAxios(base)
const { data } = await client.get('/', { params: { _: Date.now() } })
return data
}
export function netInVenue(v: Pick<HikPeopleCountingVenue, 'enter' | 'exit'>): number {
return Math.max(0, Number(v.enter ?? 0) - Number(v.exit ?? 0))
}
/** 展示用includedGroups 支持数组或字符串 */
export function formatIncludedGroups(g: HikPeopleCountingVenue['includedGroups']): string {
if (g == null) return ''
if (Array.isArray(g)) return g.filter(Boolean).join('、')
return String(g).trim()
}
export function healthStatusColor(status: string): string {
const s = String(status || '').toUpperCase()
if (s === 'UP') return 'green'
if (s === 'DEGRADED') return 'orangered'
return 'red'
}

@ -13,6 +13,8 @@ export const routeLoaders: Record<string, () => Promise<{ default: unknown }>> =
'/traffic': () => import('../views/traffic/Monitor.vue'),
'/traffic/leaderboard': () => import('../views/traffic/Leaderboard.vue'),
'/traffic/alerts': () => import('../views/traffic/Alerts.vue'),
'/hik-camera': () => import('../views/hik-camera/HikPeopleCounting.vue'),
'/hik-camera/HikPeopleCounting': () => import('../views/hik-camera/HikPeopleCounting.vue'),
'/stats': () => import('../views/stats/Overview.vue'),
'/stats/regions': () => import('../views/stats/Regions.vue'),
'/stats/categories': () => import('../views/stats/Categories.vue'),

@ -0,0 +1,586 @@
<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 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())
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 ?? [])
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()
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
Message.warning('请选择合法日期 yyyy-MM-dd')
return
}
loadingPc.value = true
try {
peopleData.value = await fetchHikPeopleCounting(d)
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 bodyMsg =
typeof raw === 'object' && raw != null && 'message' in raw
? String((raw as { message?: string }).message)
: ''
const msg =
e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e)
if (st === 404) {
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 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" />
<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>
</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 }"
>
<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>
</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>
Loading…
Cancel
Save