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.

733 lines
28 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden 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, 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>