From e9670d3e21066ccc8b0bf4fa63f2ee02105742b7 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Fri, 15 May 2026 18:27:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BA=BA=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hikPdcClient.ts | 238 +++++++++ src/router/routeLoaders.ts | 2 + src/views/hik-camera/HikPeopleCounting.vue | 586 +++++++++++++++++++++ 3 files changed, 826 insertions(+) create mode 100644 src/api/hikPdcClient.ts create mode 100644 src/views/hik-camera/HikPeopleCounting.vue diff --git a/src/api/hikPdcClient.ts b/src/api/hikPdcClient.ts new file mode 100644 index 0000000..8c91fd0 --- /dev/null +++ b/src/api/hikPdcClient.ts @@ -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 { + 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('/api/people-counting', { params }) + return data +} + +export async function fetchHikHealth(): Promise { + const base = getHikPdcApiBase() + if (!base) { + throw new Error('未配置 VITE_PEOPLE_COUNTING_URL,无法推导 /api/health 基址') + } + const client = externalAxios(base) + const { data } = await client.get('/api/health') + return data +} + +/** GET /api/venues/hourly?date=yyyy-MM-dd&venueId=可选 */ +export async function fetchHikVenuesHourly(date: string, venueId?: string): Promise { + const base = getHikPdcApiBase() + if (!base) { + throw new Error('未配置 VITE_PEOPLE_COUNTING_URL,无法请求 /api/venues/hourly') + } + const client = externalAxios(base) + const { data } = await client.get('/api/venues/hourly', { + params: { + date: date.trim(), + ...(venueId && String(venueId).trim() ? { venueId: String(venueId).trim() } : {}), + }, + }) + return data +} + +/** GET /api/device-links,probe/live 会做网络探测(可能较慢) */ +export async function fetchHikDeviceLinks(options?: { + probe?: boolean + live?: boolean + tcpTimeoutMs?: number +}): Promise { + 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 = {} + 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('/api/device-links', { params }) + return data +} + +/** POST /api/device-links/retry */ +export async function postHikDeviceLinksRetry(payload: { + deviceIndices: number[] + reloadConfig?: boolean +}): Promise { + 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('/api/device-links/retry', payload) + return data +} + +export async function fetchHikApiRoot(): Promise { + 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): 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' +} diff --git a/src/router/routeLoaders.ts b/src/router/routeLoaders.ts index 2d7d8d5..ca864bb 100644 --- a/src/router/routeLoaders.ts +++ b/src/router/routeLoaders.ts @@ -13,6 +13,8 @@ export const routeLoaders: Record 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'), diff --git a/src/views/hik-camera/HikPeopleCounting.vue b/src/views/hik-camera/HikPeopleCounting.vue new file mode 100644 index 0000000..0219fc7 --- /dev/null +++ b/src/views/hik-camera/HikPeopleCounting.vue @@ -0,0 +1,586 @@ + + + + +