用户鉴权

master
lion 3 weeks ago
parent 1abae6e0cf
commit f85dd9cf1f

@ -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<HikPeopleCountingResponse> {
/**
* @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<HikPeopleCountingResponse> {
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<HikPeopleCo
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 })
const ed = (endDate || '').trim()
const params: Record<string, string> = {}
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<HikPeopleCountingResponse>('/api/people-counting', {
params: Object.keys(params).length ? params : undefined,
})
return data
}

@ -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<string[]>([])
const pcLoading = ref(false)
const pcError = ref<string | null>(null)
const pcData = ref<HikPeopleCountingResponse | null>(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<HikVenueRangeTotalRow, 'venueId' | 'venueName' | 'enter'>
/** 区间内优先用 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 () => {
</div>
</article>
</div>
<div class="dash-venue-pc-bundle">
<article class="dash-metric-card dash-metric-card--venue-pc">
<header class="dash-metric-card__head">
<div class="dash-metric-card__icon dash-metric-card__icon--venue-pc" aria-hidden="true">
<IconBarChart />
</div>
<div class="dash-metric-card__head-main">
<h2 class="dash-metric-card__title">各场馆人数统计</h2>
</div>
</header>
<div class="dash-metric-card__body dash-metric-card__body--venue-pc">
<template v-if="!peopleCountingConfigured">
<a-typography-text type="warning">
未配置 VITE_PEOPLE_COUNTING_URL 时无法加载该项请在构建环境变量中配置海康客流 people-counting 完整接口地址
</a-typography-text>
</template>
<template v-else>
<div class="dash-venue-pc-filters">
<a-space wrap size="small">
<span class="dash-venue-pc-filters__label">时间段</span>
<a-range-picker
v-model="pcDateRange"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:exchange-time="false"
allow-clear
style="width: 260px"
size="small"
/>
<a-button type="primary" size="small" :loading="pcLoading" @click="onVenuePcSearchClick"></a-button>
<a-divider direction="vertical" class="dash-venue-pc-filters__vdiv" />
<a-button size="small" :disabled="pcLoading" @click="quickVenuePeopleRangeAndLoad('week')"></a-button>
<a-button size="small" :disabled="pcLoading" @click="quickVenuePeopleRangeAndLoad('month')"></a-button>
<a-button size="small" :disabled="pcLoading" @click="quickVenuePeopleRangeAndLoad('year')"></a-button>
</a-space>
</div>
<a-spin :loading="pcLoading">
<a-alert v-if="pcError" type="warning" show-icon style="margin-bottom: 10px">{{ pcError }}</a-alert>
<div v-if="dashboardVenuePcRows.length" class="dash-venue-pc-table-wrap">
<a-table
class="dash-table dash-venue-pc-table"
row-key="venueId"
:columns="dashboardVenuePcColumns"
:data="dashboardVenuePcRows"
:pagination="false"
size="small"
table-layout-fixed
:scroll="{ y: 260 }"
:summary="dashboardVenuePcSummary"
/>
</div>
<a-empty v-else-if="!pcLoading && !pcError" description="暂无数据,可调时间段或检查客流归档与场馆映射" />
</a-spin>
</template>
</div>
</article>
</div>
</section>
<section v-if="showTicketGrabDetailPanel" class="dash-section dash-section--surface ticket-grab-panel">
@ -665,6 +903,7 @@ onMounted(async () => {
<a-empty v-else-if="!tgLoading" description="请选择抢票活动后查询" />
</a-spin>
</section>
</div>
</div>
</template>
@ -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;

@ -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<HikPeopleCountingResponse | null>(null)
const healthData = ref<HikHealthResponse | null>(null)
@ -39,6 +42,17 @@ 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)
@ -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<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 || '客流接口请求失败')
}
@ -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 = [
<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>
<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>
@ -408,6 +509,37 @@ const nestedSubgroupColumns = [
<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="暂无数据,请先拉取客流" />
@ -428,6 +560,7 @@ const nestedSubgroupColumns = [
:pagination="false"
size="small"
:scroll="{ x: 1300, y: 280 }"
:summary="venuesDailySummary"
>
<template #net="{ record }">{{ netInVenue(record) }}</template>
<template #venueLastUpdate="{ record }">{{ formatZhYmdHms(record.lastUpdate) }}</template>
@ -443,6 +576,19 @@ const nestedSubgroupColumns = [
</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">
暂无场馆数据时请检查服务端场馆与统计组映射是否已正确配置

Loading…
Cancel
Save