|
|
|
|
@ -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;
|
|
|
|
|
|