From f85dd9cf1f7dcd20f95dfd6376f6f72624bfba73 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Wed, 20 May 2026 19:20:37 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hikPdcClient.ts | 39 ++- src/views/Dashboard.vue | 284 ++++++++++++++++++++- src/views/hik-camera/HikPeopleCounting.vue | 160 +++++++++++- 3 files changed, 468 insertions(+), 15 deletions(-) diff --git a/src/api/hikPdcClient.ts b/src/api/hikPdcClient.ts index 8c91fd0..89338ee 100644 --- a/src/api/hikPdcClient.ts +++ b/src/api/hikPdcClient.ts @@ -35,6 +35,15 @@ export type HikPeopleCountingVenue = { groups?: HikPeopleCountingGroup[] } +/** `GET /api/people-counting` 区间内各馆 enter/exit/passing 逐日之和(见第三方接入 API 说明 §3) */ +export type HikVenueRangeTotalRow = { + venueId: string + venueName?: string + enter: number + exit: number + passing: number +} + export type HikPeopleCountingResponse = { code: number message?: string @@ -51,6 +60,13 @@ export type HikPeopleCountingResponse = { } groups?: HikPeopleCountingGroup[] venues?: HikPeopleCountingVenue[] + /** 区间起始日(与 `date`/`end_date` 查询一致时由服务端返回) */ + rangeFrom?: string + /** 区间结束日 */ + rangeTo?: string + venuesRangeTotals?: HikVenueRangeTotalRow[] + venuesRangeMissingDates?: string[] + venuesRangeTotalsNote?: string } export type HikHealthResponse = { @@ -132,8 +148,11 @@ function externalAxios(baseURL: string, timeout = EXTERNAL_TIMEOUT_MS) { }) } -/** @param date yyyy-MM-dd;传今日与内存当日汇总一致,其他日期需服务端存在 `people_counting_{date}.json` 归档 */ -export async function fetchHikPeopleCounting(date?: string): Promise { +/** + * @param date yyyy-MM-dd,对应查询参数 `date` + * @param endDate yyyy-MM-dd,可选,对应查询参数 `end_date`;与 `date` 同时传时为闭区间 `[date, end_date]` + */ +export async function fetchHikPeopleCounting(date?: string, endDate?: string): Promise { const url = getPeopleCountingEndpoint() if (!url) { throw new Error('未配置 VITE_PEOPLE_COUNTING_URL(海康客流 people-counting 完整地址)') @@ -141,11 +160,17 @@ export async function fetchHikPeopleCounting(date?: string): Promise('/api/people-counting', { params }) + const ed = (endDate || '').trim() + const params: Record = {} + if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) { + params.date = d + } + if (ed && /^\d{4}-\d{2}-\d{2}$/.test(ed)) { + params.end_date = ed + } + const { data } = await client.get('/api/people-counting', { + params: Object.keys(params).length ? params : undefined, + }) return data } diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index ad1d3f5..eb98679 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -3,6 +3,12 @@ import { computed, onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { Message } from '@arco-design/web-vue' import { IconApps, IconBarChart, IconCalendar, IconGift, IconOrderedList, IconUser } from '@arco-design/web-vue/es/icon' +import { + fetchHikPeopleCounting, + getPeopleCountingEndpoint, + type HikPeopleCountingResponse, + type HikVenueRangeTotalRow, +} from '../api/hikPdcClient' import { http } from '../api/http' const router = useRouter() @@ -73,6 +79,169 @@ const stats = ref({ const rankTableScrollY = 300 +/** 是否已配置直连海康客流 people-counting 地址(工作台底部「各场馆人数统计」用) */ +const peopleCountingConfigured = computed(() => !!getPeopleCountingEndpoint()) + +/** —— dash-bundle「各场馆人数统计」:[起始日, 结束日],与 a-range-picker 绑定 —— */ +const pcDateRange = ref([]) +const pcLoading = ref(false) +const pcError = ref(null) +const pcData = ref(null) + +/** 表格列宽用 minWidth,外层 100% 宽度,便于撑满版块 */ +const dashboardVenuePcColumns = [ + { title: '场馆ID', dataIndex: 'venueId', minWidth: 110, ellipsis: true, tooltip: true }, + { title: '场馆名称', dataIndex: 'venueName', minWidth: 140, ellipsis: true, tooltip: true }, + { title: '入馆总人数', dataIndex: 'enter', minWidth: 120 }, +] + +function ymdCalendar(d: Date): string { + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +} + +/** 当周周一(自然周:周一至周日) */ +function startOfCalendarWeekMonday(d: Date): Date { + const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()) + const w = x.getDay() + const offset = w === 0 ? -6 : 1 - w + x.setDate(x.getDate() + offset) + return x +} + +function endOfCalendarWeekSunday(monday: Date): Date { + const x = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate()) + x.setDate(x.getDate() + 6) + return x +} + +function todayCalendar(): Date { + const t = new Date() + return new Date(t.getFullYear(), t.getMonth(), t.getDate()) +} + +function minCalendarDate(a: Date, b: Date): Date { + return a.getTime() <= b.getTime() ? a : b +} + +/** 快捷:本周、本月、本年(均不超过今天);同步到 pcDateRange */ +function applyQuickVenuePeopleRange(preset: 'week' | 'month' | 'year') { + const today = todayCalendar() + let startStr: string + let endStr: string + if (preset === 'week') { + const mon = startOfCalendarWeekMonday(today) + const sun = endOfCalendarWeekSunday(mon) + startStr = ymdCalendar(mon) + endStr = ymdCalendar(minCalendarDate(sun, today)) + } else if (preset === 'month') { + const first = new Date(today.getFullYear(), today.getMonth(), 1) + const last = new Date(today.getFullYear(), today.getMonth() + 1, 0) + startStr = ymdCalendar(first) + endStr = ymdCalendar(minCalendarDate(last, today)) + } else { + const first = new Date(today.getFullYear(), 0, 1) + const last = new Date(today.getFullYear(), 11, 31) + startStr = ymdCalendar(first) + endStr = ymdCalendar(minCalendarDate(last, today)) + } + pcDateRange.value = [startStr, endStr] +} + +function applyVenuePcRangeToday() { + const d = ymdCalendar(todayCalendar()) + pcDateRange.value = [d, d] +} + +type DashboardVenuePcRow = Pick + +/** 区间内优先用 venuesRangeTotals;单日或无区间汇总时用 venues(当日快照);按「进入人数」降序 */ +const dashboardVenuePcRows = computed((): DashboardVenuePcRow[] => { + const p = pcData.value + if (!p || p.code !== 200) return [] + let raw: DashboardVenuePcRow[] = [] + const rangeTotals = p.venuesRangeTotals + if (Array.isArray(rangeTotals) && rangeTotals.length > 0) { + raw = rangeTotals.map((row) => ({ + venueId: row.venueId, + venueName: row.venueName, + enter: Number(row.enter) || 0, + })) + } else { + const v = p.venues + if (!Array.isArray(v) || v.length === 0) return [] + raw = v.map((x) => ({ + venueId: x.venueId, + venueName: x.venueName, + enter: Number(x.enter) || 0, + })) + } + return raw.sort((a, b) => { + const d = (Number(b.enter) || 0) - (Number(a.enter) || 0) + if (d !== 0) return d + return String(a.venueId).localeCompare(String(b.venueId), undefined, { numeric: true }) + }) +}) + +function dashboardVenuePcSummary(ctx: { data: DashboardVenuePcRow[] }) { + const rows = Array.isArray(ctx.data) ? ctx.data : [] + let enter = 0 + for (const r of rows) { + enter += Number(r.enter) || 0 + } + return [ + { + venueId: '合计', + venueName: '—', + enter, + }, + ] +} + +function pickVenuePcRangeFromPicker(): { start: string; end: string } | null { + const arr = pcDateRange.value + if (!Array.isArray(arr) || arr.length < 2) return null + const start = String(arr[0] ?? '').trim() + const end = String(arr[1] ?? '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end)) return null + if (end < start) return null + return { start, end } +} + +async function loadVenuePeopleRange(options?: { silentSuccess?: boolean }) { + if (!getPeopleCountingEndpoint()) return + const picked = pickVenuePcRangeFromPicker() + if (!picked) { + Message.warning('请选择合法的日期时间段(起止 yyyy-MM-dd)') + return + } + const { start, end } = picked + pcLoading.value = true + pcError.value = null + const silentOk = options?.silentSuccess === true + try { + pcData.value = await fetchHikPeopleCounting(start, end) + if (pcData.value.code !== 200) { + pcError.value = pcData.value.message || `接口返回错误码 ${pcData.value.code}` + if (!silentOk) Message.warning(pcError.value) + } else if (!silentOk) { + Message.success('客流统计已刷新') + } + } catch (e: unknown) { + pcData.value = null + const msg = + e && typeof e === 'object' && 'message' in e ? String((e as { message?: string }).message) : String(e) + pcError.value = msg || '客流接口请求失败' + Message.error(pcError.value || '客流接口请求失败') + } finally { + pcLoading.value = false + } +} + +function onVenuePcSearchClick() { + void loadVenuePeopleRange({ silentSuccess: false }) +} + /** 底部抢票详情(选活动、明细表等);需要时再改为 true */ const showTicketGrabDetailPanel = false @@ -273,6 +442,11 @@ function formatVerifyCellLines(cell: TgDailyVerifyCell | undefined) { return { main: `${cell.verified}/${cell.total}`, pct: `(${cell.pct}%)` } } +function quickVenuePeopleRangeAndLoad(preset: 'week' | 'month' | 'year') { + applyQuickVenuePeopleRange(preset) + void loadVenuePeopleRange({ silentSuccess: true }) +} + async function exportDailyVerifyExcel() { if (!tgFilters.eventId) return exportingDailyVerify.value = true @@ -312,6 +486,10 @@ async function exportDailyVerifyExcel() { onMounted(async () => { await loadMe() await loadStats() + if (getPeopleCountingEndpoint()) { + applyVenuePcRangeToday() + await loadVenuePeopleRange({ silentSuccess: true }) + } if (showTicketGrabDetailPanel) { await loadTicketGrabEventOptions() if (tgFilters.eventId) { @@ -534,6 +712,66 @@ onMounted(async () => { + +
+
+
+ +
+

各场馆人数统计

+
+
+
+ + +
+
+
@@ -665,6 +903,7 @@ onMounted(async () => {
+ @@ -1415,11 +1654,54 @@ onMounted(async () => { } :deep(.dash-table .arco-table-container), -:deep(.ticket-grab-panel .arco-table-container) { +:deep(.ticket-grab-panel .arco-table-container), +:deep(.dash-venue-pc-table.arco-table .arco-table-container) { border-radius: 8px; overflow: hidden; } +.dash-venue-pc-bundle { + width: 100%; + min-width: 0; +} + +.dash-metric-card--venue-pc { + width: 100%; +} + +.dash-metric-card__icon--venue-pc { + background: linear-gradient(145deg, #e8f6ff 0%, #f5fbff 100%); + color: #0e8ee9; +} + +.dash-metric-card__body--venue-pc { + min-height: 0; +} + +.dash-venue-pc-table-wrap { + width: 100%; +} + +.dash-venue-pc-bundle :deep(.dash-venue-pc-table.arco-table), +.dash-venue-pc-table-wrap :deep(.dash-venue-pc-table.arco-table) { + width: 100%; +} + +.dash-venue-pc-filters { + margin-bottom: 12px; +} + +.dash-venue-pc-filters__label { + font-size: 13px; + color: #4e5969; +} + +.dash-venue-pc-filters__vdiv { + margin: 0 4px; + height: 20px; + border-left-color: #e5e8ed; +} + @media (max-width: 1200px) { .tg-three-col { grid-template-columns: 1fr; diff --git a/src/views/hik-camera/HikPeopleCounting.vue b/src/views/hik-camera/HikPeopleCounting.vue index 0219fc7..0cf85bb 100644 --- a/src/views/hik-camera/HikPeopleCounting.vue +++ b/src/views/hik-camera/HikPeopleCounting.vue @@ -15,6 +15,7 @@ import { type HikPeopleCountingGroup, type HikPeopleCountingVenue, type HikPeopleCountingResponse, + type HikVenueRangeTotalRow, type HikHealthResponse, type HikVenueHourlyRow, type HikVenuesHourlyResponse, @@ -31,6 +32,8 @@ function todayYmd(): string { const activeTab = ref('realtime') const peopleCountingDate = ref(todayYmd()) +/** 对应请求参数 end_date;留空则仅按起始日单日查询 */ +const peopleCountingEndDate = ref('') const peopleData = ref(null) const healthData = ref(null) @@ -39,6 +42,17 @@ const lastFetchAt = ref(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(undefined) const hourlyData = ref(null) @@ -71,13 +85,22 @@ function filterHourlyVenueOption(inputValue: string, option: { label?: string; v 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') + 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) + 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})`) @@ -89,14 +112,17 @@ async function refreshPeopleCounting(showToast = false) { 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 rawObj = + typeof raw === 'object' && raw != null ? (raw as Record) : 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 || '客流接口请求失败') } @@ -238,6 +264,72 @@ 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 }, @@ -360,8 +452,17 @@ const nestedSubgroupColumns = [ - 统计日期 + 统计起始日 + 统计结束日 + + 留空则仅查询起始日单日(不传 end_date) 拉取客流 健康检查 全部刷新 @@ -408,6 +509,37 @@ const nestedSubgroupColumns = [
累计接收数据条数:{{ peopleData.total.dataCount }}
+
+ 查询区间:{{ peopleData.rangeFrom ?? '—' }} ~ {{ peopleData.rangeTo ?? '—' }}(快照日为「数据日期」,区间内累计见下方表格) +
+ + + + + {{ peopleData.venuesRangeTotalsNote }} + +
+ 区间内缺失归档的日期:{{ missingRangeDatesText() }} +
+ +
暂无区间内各馆累计数据
@@ -428,6 +560,7 @@ const nestedSubgroupColumns = [ :pagination="false" size="small" :scroll="{ x: 1300, y: 280 }" + :summary="venuesDailySummary" > @@ -443,6 +576,19 @@ const nestedSubgroupColumns = [ +
暂无场馆数据时,请检查服务端场馆与统计组映射是否已正确配置。