From 5bccbf38a6c8e837208c852a1994d56749d838fa Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Thu, 4 Jun 2026 16:08:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=BA=E9=A6=86=E5=AE=A1=E6=A0=B8=E7=AD=89?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard.vue | 242 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 3 deletions(-) diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index b40bda2..96eccfb 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -171,8 +171,7 @@ function applyVenuePcRangeToday() { type DashboardVenuePcRow = Pick /** 区间内优先用 venuesRangeTotals;单日或无区间汇总时用 venues(当日快照);按「进入人数」降序 */ -const dashboardVenuePcRows = computed((): DashboardVenuePcRow[] => { - const p = pcData.value +function parseVenuePcRowsFromResponse(p: HikPeopleCountingResponse | null): DashboardVenuePcRow[] { if (!p || p.code !== 200) return [] let raw: DashboardVenuePcRow[] = [] const rangeTotals = p.venuesRangeTotals @@ -196,7 +195,9 @@ const dashboardVenuePcRows = computed((): DashboardVenuePcRow[] => { if (d !== 0) return d return String(a.venueId).localeCompare(String(b.venueId), undefined, { numeric: true }) }) -}) +} + +const dashboardVenuePcRows = computed((): DashboardVenuePcRow[] => parseVenuePcRowsFromResponse(pcData.value)) function dashboardVenuePcSummary(ctx: { data: DashboardVenuePcRow[] }) { const rows = Array.isArray(ctx.data) ? ctx.data : [] @@ -322,6 +323,238 @@ const ticketGrabStats = ref<{ const dailyVerifyLabels = computed(() => ticketGrabStats.value?.daily_verify_matrix?.date_labels ?? []) const dailyVerifyRows = computed(() => ticketGrabStats.value?.daily_verify_matrix?.rows ?? []) const exportingVenuePc = ref(false) +const exportingDashboard = ref(false) + +function sanitizeExcelSheetName(name: string): string { + return name.replace(/[\\/?*[\]:]/g, '_').slice(0, 31) || 'Sheet1' +} + +/** Excel sheet 名不可重复;重名时自动追加 (2)、(3)… */ +function appendDashboardWorkbookSheet( + wb: XLSX.WorkBook, + ws: XLSX.WorkSheet, + desiredName: string, +) { + const base = sanitizeExcelSheetName(desiredName) + let name = base + let n = 2 + while (wb.SheetNames.includes(name)) { + const suffix = `(${n})` + name = sanitizeExcelSheetName(`${base.slice(0, Math.max(1, 31 - suffix.length))}${suffix}`) + n += 1 + } + XLSX.utils.book_append_sheet(wb, ws, name) +} + +function dashboardExportFilename(): string { + const d = new Date() + 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())}统计.xlsx` +} + +function buildMetricSheet(pairs: Array<[string, string | number]>) { + return XLSX.utils.aoa_to_sheet([['指标', '数值'], ...pairs.map(([k, v]) => [k, v])]) +} + +function buildVenuePcExportSheet(rangeLabel: string, rows: DashboardVenuePcRow[], note?: string) { + if (note) { + return XLSX.utils.aoa_to_sheet([['统计区间', rangeLabel], ['说明', note]]) + } + if (!rows.length) { + return XLSX.utils.aoa_to_sheet([['统计区间', rangeLabel], [], ['说明', '暂无数据']]) + } + const enterSum = rows.reduce((s, r) => s + (Number(r.enter) || 0), 0) + return XLSX.utils.aoa_to_sheet([ + ['统计区间', rangeLabel], + [], + ['场馆ID', '场馆名称', '入馆总人数'], + ...rows.map((r) => [r.venueId, r.venueName, r.enter]), + ['合计', '—', enterSum], + ]) +} + +type VenuePcExportPreset = { + start: string + end: string + periodLabel: string + sheetName: string +} + +function buildVenuePcExportPreset(kind: 'today' | 'week' | 'month' | 'year'): VenuePcExportPreset { + const today = todayCalendar() + const todayStr = ymdCalendar(today) + if (kind === 'today') { + return { + start: todayStr, + end: todayStr, + periodLabel: todayStr, + sheetName: `各场馆人数统计-当天(${todayStr})`, + } + } + if (kind === 'week') { + const mon = startOfCalendarWeekMonday(today) + const start = ymdCalendar(mon) + const end = ymdCalendar(minCalendarDate(endOfCalendarWeekSunday(mon), today)) + return { + start, + end, + periodLabel: `${start}至${end}`, + sheetName: `各场馆人数统计-本周(${start}至${end})`, + } + } + if (kind === 'month') { + const first = new Date(today.getFullYear(), today.getMonth(), 1) + const last = new Date(today.getFullYear(), today.getMonth() + 1, 0) + const start = ymdCalendar(first) + const end = ymdCalendar(minCalendarDate(last, today)) + return { + start, + end, + periodLabel: `${start}至${end}`, + sheetName: `各场馆人数统计-本月(${start}至${end})`, + } + } + const first = new Date(today.getFullYear(), 0, 1) + const last = new Date(today.getFullYear(), 11, 31) + const start = ymdCalendar(first) + const end = ymdCalendar(minCalendarDate(last, today)) + return { + start, + end, + periodLabel: `${start}至${end}`, + sheetName: `各场馆人数统计-本年(${start}至${end})`, + } +} + +function dashboardTicketGrabVerifyRatePctStr(): string { + const t = stats.value.ticket_grab_schedule_counts + if (!t || t.booked_people <= 0) return '0%' + const p = t.verify_rate_pct + return `${p != null ? p : 0}%` +} + +async function exportDashboardExcel() { + if (exportingDashboard.value) return + exportingDashboard.value = true + try { + const wb = XLSX.utils.book_new() + const s = stats.value + const summary = s.summary ?? ({} as typeof s.summary) + const act = s.activity_schedule_counts ?? ({} as ActivityScheduleCountsBlock) + const tg = s.ticket_grab_schedule_counts ?? ({} as TicketGrabScheduleCountBlock) + const publishRanking = Array.isArray(s.activity_publish_ranking) ? s.activity_publish_ranking : [] + const liveRanking = Array.isArray(s.live_people_ranking) ? s.live_people_ranking : [] + + const corePairs: Array<[string, string | number]> = [ + ['现有场馆', summary.venues_count ?? 0], + ['总预约次数', summary.reservation_order_count ?? 0], + ] + if (isSuperAdmin.value) { + corePairs.push(['总用户数', summary.wechat_user_count ?? 0]) + } + corePairs.push(['预约用户数', summary.user_count ?? 0]) + if (isSuperAdmin.value) { + corePairs.push(['总访问量', summary.home_visit_total ?? 0]) + corePairs.push(['今日访问量', summary.home_visit_today ?? 0]) + } + appendDashboardWorkbookSheet(wb, buildMetricSheet(corePairs), '核心数据统计') + + appendDashboardWorkbookSheet( + wb, + buildMetricSheet([ + ['活动数', act.total ?? 0], + ['总场次', act.total_sessions ?? 0], + ['未开始', act.not_started ?? 0], + ['进行中', act.ongoing ?? 0], + ['已结束', act.ended ?? 0], + ['已发布活动场馆', act.published_venues_count ?? 0], + ]), + '活动统计', + ) + + appendDashboardWorkbookSheet( + wb, + buildMetricSheet([ + ['总场数', tg.total ?? 0], + ['未开始', tg.not_started ?? 0], + ['进行中', tg.ongoing ?? 0], + ['已结束', tg.ended ?? 0], + ['核销率', dashboardTicketGrabVerifyRatePctStr()], + ['已核销人数', tg.verified_people ?? 0], + ['已约人数', tg.booked_people ?? 0], + ]), + '抢票统计', + ) + + const publishRows = publishRanking.map((r, i) => [ + i + 1, + r.venue_id, + r.venue_name, + r.published_count, + r.published_sessions_count, + ]) + appendDashboardWorkbookSheet( + wb, + XLSX.utils.aoa_to_sheet([ + ['排名', '场馆ID', '场馆名称', '发布活动数', '发布场次数'], + ...publishRows, + ]), + '活动发布排行', + ) + + const liveRows = liveRanking.map((r, i) => [i + 1, r.venue_id, r.venue_name, r.live_count]) + appendDashboardWorkbookSheet( + wb, + XLSX.utils.aoa_to_sheet([['排名', '场馆ID', '场馆名称', '在馆人数'], ...liveRows]), + '实时人数排行', + ) + + const pcPresets: VenuePcExportPreset[] = ( + ['today', 'week', 'month', 'year'] as const + ).map((k) => buildVenuePcExportPreset(k)) + + if (!getPeopleCountingEndpoint()) { + for (const preset of pcPresets) { + appendDashboardWorkbookSheet( + wb, + buildVenuePcExportSheet(preset.periodLabel, [], '未配置客流统计接口'), + preset.sheetName, + ) + } + } else { + const pcResponses = await Promise.all( + pcPresets.map((preset) => + fetchHikPeopleCounting(preset.start, preset.end).catch(() => null), + ), + ) + pcPresets.forEach((preset, idx) => { + const resp = pcResponses[idx] + const rows = parseVenuePcRowsFromResponse(resp) + const note = + resp && resp.code !== 200 ? resp.message || `接口返回错误码 ${resp.code}` : undefined + appendDashboardWorkbookSheet( + wb, + buildVenuePcExportSheet(preset.periodLabel, rows, note), + preset.sheetName, + ) + }) + } + + XLSX.writeFile(wb, dashboardExportFilename()) + Message.success('数据看板已导出') + } catch (err: unknown) { + console.error('[工作台] 导出数据看板失败', err) + const msg = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : '导出失败' + Message.error(msg.includes('already exists') ? '导出失败:Sheet 名称重复,请刷新后重试' : msg || '导出失败') + } finally { + exportingDashboard.value = false + } +} function exportVenuePeopleExcel() { const picked = pickVenuePcRangeFromPicker() @@ -563,6 +796,9 @@ onMounted(async () => {

工作台

数据看板 + + 导出数据 +