From 1725b16a41e17750cbd1a39a38ee08d104aa8e48 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Wed, 13 May 2026 15:55:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/exportActivityListXlsx.ts | 251 ++++++++++++++++++ .../exportActivityRegistrationsListXlsx.ts | 87 ++++++ src/utils/exportActivityVerifyListXlsx.ts | 85 ++++++ src/utils/exportTicketGrabListXlsx.ts | 192 ++++++++++++++ .../exportTicketGrabRegistrationsListXlsx.ts | 55 ++++ src/utils/exportXlsx.ts | 43 +++ src/views/activities/ActivityList.vue | 62 ++++- src/views/activities/Registrations.vue | 204 +++----------- src/views/activities/Verify.vue | 21 ++ src/views/ticket-grab/TicketGrabList.vue | 58 +++- .../ticket-grab/TicketGrabRegistrations.vue | 71 ++++- 11 files changed, 932 insertions(+), 197 deletions(-) create mode 100644 src/utils/exportActivityListXlsx.ts create mode 100644 src/utils/exportActivityRegistrationsListXlsx.ts create mode 100644 src/utils/exportActivityVerifyListXlsx.ts create mode 100644 src/utils/exportTicketGrabListXlsx.ts create mode 100644 src/utils/exportTicketGrabRegistrationsListXlsx.ts create mode 100644 src/utils/exportXlsx.ts diff --git a/src/utils/exportActivityListXlsx.ts b/src/utils/exportActivityListXlsx.ts new file mode 100644 index 0000000..2073f8d --- /dev/null +++ b/src/utils/exportActivityListXlsx.ts @@ -0,0 +1,251 @@ +import * as XLSX from 'xlsx' + +function str(v: unknown): string { + if (v == null || v === '') return '' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + return JSON.stringify(v) +} + +function num(v: unknown): string | number { + if (v === null || v === undefined || v === '') return '' + const n = Number(v) + return Number.isFinite(n) ? n : '' +} + +function fmtDateTime(v: unknown): string { + if (v == null || v === '') return '' + const s = String(v) + return s.replace('T', ' ').slice(0, 19) +} + +function yn(v: unknown): string { + if (v === true || v === 1 || v === '1' || v === 'true') return '是' + if (v === false || v === 0 || v === '0' || v === 'false') return '否' + return '' +} + +function stripHtml(html: unknown, max = 12000): string { + const s = String(html ?? '').trim() + if (!s) return '' + return s + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max) +} + +function reservationTypeLabel(t: unknown): string { + const s = String(t ?? '').trim() + if (s === 'online') return '公益性需预约活动' + if (s === 'none') return '公益性无需预约活动' + if (s === 'paid_study') return '收费科普研学活动' + if (s === 'phone') return '电话预约' + if (s === 'wechat_mp') return '公众号预约' + if (s === 'offline_visit' || s === 'offline') return '线下预约' + if (s === 'other') return '外链跳转' + if (!s) return '-' + return s +} + +function scheduleStatusLabel(s: unknown): string { + const v = String(s ?? '').trim() + if (v === 'not_started') return '未开始' + if (v === 'ended') return '已结束' + if (v === 'ongoing') return '进行中' + return v || '-' +} + +function auditStatusLabel(s: unknown): string { + const v = String(s ?? '').trim() + if (v === 'pending') return '待审核' + if (v === 'rejected') return '已退回' + if (v === 'approved') return '已通过' + return v || '-' +} + +function bookingAudienceLabel(s: unknown): string { + const v = String(s ?? '').trim() + if (v === 'individual') return '个人' + if (v === 'group') return '团体' + if (v === 'both') return '个人+团体' + return v || '-' +} + +function venueName(row: Record): string { + const v = row.venue + if (v && typeof v === 'object' && !Array.isArray(v)) { + return String((v as { name?: string }).name ?? '') + } + return '' +} + +function tagsJoin(tags: unknown): string { + if (!Array.isArray(tags)) return '' + return tags.map((x) => String(x)).filter(Boolean).join('、') +} + +function mediaUrls(media: unknown): string { + if (!Array.isArray(media)) return '' + return media + .map((m) => { + if (m && typeof m === 'object' && 'url' in m) return String((m as { url: string }).url) + return '' + }) + .filter(Boolean) + .join('\n') +} + +/** 活动主体列:中文表头 + 枚举转中文(不含场次,场次单独二级表头) */ +const BASE_COL_SPEC: { zh: string; format: (row: Record) => string | number }[] = [ + { zh: '活动名称', format: (r) => str(r.title) }, + { zh: '场馆名称', format: venueName }, + { zh: '活动性质', format: (r) => reservationTypeLabel(r.reservation_type) }, + { zh: '活动状态', format: (r) => scheduleStatusLabel(r.schedule_status) }, + { zh: '审核状态', format: (r) => auditStatusLabel(r.audit_status) }, + { zh: '上架', format: (r) => yn(r.is_active) }, + { zh: '热门', format: (r) => yn(r.is_hot) }, + { zh: '活动开始', format: (r) => fmtDateTime(r.start_at) }, + { zh: '活动结束', format: (r) => fmtDateTime(r.end_at) }, + { zh: '联系人', format: (r) => str(r.contact_name) }, + { zh: '联系电话', format: (r) => str(r.contact_phone) }, + { zh: '详情正文', format: (r) => stripHtml(r.detail_html) }, + { zh: '活动地点', format: (r) => str(r.location) }, + { zh: '报到集合点', format: (r) => str(r.check_in_meeting_point) }, + { zh: '标签', format: (r) => tagsJoin(r.tags) }, + { zh: '预约方式/参与提示/报名方式', format: (r) => str(r.booking_method_note) }, + { zh: '费用', format: (r) => str(r.ticket_fee_note) }, + { zh: '预约对象', format: (r) => bookingAudienceLabel(r.booking_audience) }, + { zh: '已报名人数', format: (r) => num(r.registered_count) }, + { zh: '浏览量', format: (r) => num(r.view_count) }, + { zh: '封面图', format: (r) => str(r.cover_image) }, + { zh: '轮播资源', format: (r) => mediaUrls(r.gallery_media) }, + { zh: '花絮媒体', format: (r) => mediaUrls(r.behind_scenes_media) }, + { zh: '排序', format: (r) => num(r.sort) }, + { zh: '创建时间', format: (r) => fmtDateTime(r.created_at) }, + { zh: '更新时间', format: (r) => fmtDateTime(r.updated_at) }, +] + +const SESSION_SUB_HEADERS = [ + '场次名称', + '活动日期', + '场次开始', + '场次结束', + '预约开放', + '预约截止', + '可约人数', + '已约人数', + '人数说明', +] as const + +const SESSION_FIELDS = [ + 'session_name', + 'activity_date', + 'session_start_at', + 'session_end_at', + 'booking_opens_at', + 'booking_deadline_at', + 'day_quota', + 'booked_count', + 'quota_note', +] as const + +function parseActivityDays(row: Record): Record[] { + const v = row.activity_days + if (!Array.isArray(v)) return [] + return v.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) as Record[] +} + +const MAX_SESSION_EXPORT = 40 + +/** 场次单元格格式化 */ +function fmtSessionCell(key: string, d: Record): string | number { + const raw = d[key] + if (key === 'day_quota' || key === 'booked_count') return num(raw) + if ( + key === 'activity_date' || + key === 'session_start_at' || + key === 'session_end_at' || + key === 'booking_opens_at' || + key === 'booking_deadline_at' + ) { + return fmtDateTime(raw) + } + return str(raw) +} + +/** + * 活动列表导出:中文表头、下拉/枚举已为中文可读文案;场次为多级表头(场次1 / 场次2 × 二级列) + */ +export function downloadActivityListXlsx(records: Record[]) { + const baseCols = BASE_COL_SPEC.length + const subCols = SESSION_SUB_HEADERS.length + + const maxSessions = Math.min( + MAX_SESSION_EXPORT, + Math.max(0, ...records.map((r) => parseActivityDays(r).length)), + ) + + const aoa: (string | number)[][] = [] + + if (maxSessions === 0) { + aoa.push(BASE_COL_SPEC.map((c) => c.zh)) + for (const row of records) { + aoa.push(BASE_COL_SPEC.map((spec) => spec.format(row))) + } + } else { + const row0: (string | number)[] = [...BASE_COL_SPEC.map((c) => c.zh)] + for (let s = 0; s < maxSessions; s += 1) { + row0.push(`场次${s + 1}`) + for (let k = 1; k < subCols; k += 1) row0.push('') + } + + const row1: (string | number)[] = [...BASE_COL_SPEC.map(() => '')] + for (let s = 0; s < maxSessions; s += 1) { + row1.push(...SESSION_SUB_HEADERS) + } + + aoa.push(row0, row1) + + const merges: XLSX.Range[] = [] + for (let j = 0; j < baseCols; j += 1) { + merges.push({ s: { r: 0, c: j }, e: { r: 1, c: j } }) + } + for (let s = 0; s < maxSessions; s += 1) { + const c0 = baseCols + s * subCols + merges.push({ s: { r: 0, c: c0 }, e: { r: 0, c: c0 + subCols - 1 } }) + } + + for (const row of records) { + const vals = BASE_COL_SPEC.map((spec) => spec.format(row)) + const days = parseActivityDays(row) + for (let s = 0; s < maxSessions; s += 1) { + const d = days[s] + if (!d) { + for (let k = 0; k < subCols; k += 1) vals.push('') + } else { + for (const key of SESSION_FIELDS) vals.push(fmtSessionCell(key, d)) + } + } + aoa.push(vals) + } + + const ws = XLSX.utils.aoa_to_sheet(aoa) + ws['!merges'] = merges + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '活动列表') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `活动列表-${dateText}.xlsx`) + return + } + + const ws = XLSX.utils.aoa_to_sheet(aoa) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '活动列表') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `活动列表-${dateText}.xlsx`) +} diff --git a/src/utils/exportActivityRegistrationsListXlsx.ts b/src/utils/exportActivityRegistrationsListXlsx.ts new file mode 100644 index 0000000..12c50cd --- /dev/null +++ b/src/utils/exportActivityRegistrationsListXlsx.ts @@ -0,0 +1,87 @@ +import * as XLSX from 'xlsx' +import { formatDateTimeZh, formatDateZh } from './datetime' +import { bookingTypeLabel } from './bookingType' +import { reservationStatusLabel } from './reservationStatus' + +export type ActivityRegistrationExportRow = { + id: number + visitor_name: string + visitor_phone?: string + booking_type?: string | null + ticket_count?: number + status: 'pending' | 'verified' | 'cancelled' | 'expired' + qr_token: string + created_at: string + verified_at?: string | null + venue?: { id: number; name: string } + activity?: { id: number; title: string } + activity_day?: { + activity_date: string + session_name?: string + session_start_at?: string + session_end_at?: string + } | null +} + +function formatActivitySessionTime(ad: ActivityRegistrationExportRow['activity_day']) { + if (!ad) return '-' + const name = (ad.session_name || '').trim() + if (ad.session_start_at && ad.session_end_at) { + const s = new Date(String(ad.session_start_at).replace(' ', 'T')) + const e = new Date(String(ad.session_end_at).replace(' ', 'T')) + if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) { + return [name, ad.activity_date ? formatDateZh(ad.activity_date) : ''].filter(Boolean).join(' ') + } + const y = s.getFullYear() + const m = String(s.getMonth() + 1).padStart(2, '0') + const d = String(s.getDate()).padStart(2, '0') + const pad2 = (n: number) => String(n).padStart(2, '0') + const hm = (t: Date) => `${pad2(t.getHours())}:${pad2(t.getMinutes())}` + if (s.toDateString() === e.toDateString()) { + const timePart = `${y}年${m}月${d}日 ${hm(s)}-${hm(e)}` + return name ? `${name} ${timePart}` : timePart + } + return [name, `${ad.session_start_at} ~ ${ad.session_end_at}`].filter(Boolean).join(' ') + } + return [name, ad.activity_date ? formatDateZh(ad.activity_date) : ''].filter(Boolean).join(' ') || '-' +} + +const COLS = [ + '编号', + '活动', + '场馆', + '报名人', + '手机号', + '预约类型', + '预约票数', + '场次名称', + '活动时间', + '状态', + '下单时间', + '核销时间', + '二维码 token', +] as const + +export function downloadActivityRegistrationsListXlsx(rows: ActivityRegistrationExportRow[]) { + const exportRows = rows.map((r) => ({ + 编号: r.id, + 活动: r.activity?.title ?? '', + 场馆: r.venue?.name ?? '', + 报名人: r.visitor_name, + 手机号: r.visitor_phone ?? '', + 预约类型: bookingTypeLabel(r.booking_type, r.ticket_count), + 预约票数: r.ticket_count ?? 1, + 场次名称: (r.activity_day?.session_name || '').trim() || '-', + 活动时间: formatActivitySessionTime(r.activity_day), + 状态: reservationStatusLabel(r.status), + 下单时间: formatDateTimeZh(r.created_at), + 核销时间: formatDateTimeZh(r.verified_at), + '二维码 token': r.qr_token, + })) + const ws = XLSX.utils.json_to_sheet(exportRows, { header: [...COLS] }) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '报名管理') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `报名管理-${dateText}.xlsx`) +} diff --git a/src/utils/exportActivityVerifyListXlsx.ts b/src/utils/exportActivityVerifyListXlsx.ts new file mode 100644 index 0000000..c54e9aa --- /dev/null +++ b/src/utils/exportActivityVerifyListXlsx.ts @@ -0,0 +1,85 @@ +import * as XLSX from 'xlsx' +import { formatDateTimeZh, formatDateZh } from './datetime' +import { bookingTypeLabel } from './bookingType' +import { reservationStatusLabel } from './reservationStatus' + +export type ActivityVerifyExportRow = { + visitor_name?: string + visitor_phone?: string + booking_type?: string | null + ticket_count?: number + status?: string + qr_token?: string + created_at?: string | null + verified_at?: string | null + venue?: { id?: number; name?: string } | null + activity?: { id?: number; title?: string } | null + activity_day?: { + activity_date?: string + session_name?: string + session_start_at?: string + session_end_at?: string + time_range_text?: string + } | null +} + +function formatActivitySessionTime(ad: ActivityVerifyExportRow['activity_day']) { + if (!ad) return '-' + const tr = (ad.time_range_text || '').trim() + if (tr) return tr + const name = ((ad.session_name ?? '') as string).trim() + if (ad.session_start_at && ad.session_end_at) { + const s = new Date(String(ad.session_start_at).replace(' ', 'T')) + const e = new Date(String(ad.session_end_at).replace(' ', 'T')) + if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) { + return [name, ad.activity_date ? formatDateZh(ad.activity_date) : ''].filter(Boolean).join(' ') + } + const y = s.getFullYear() + const m = String(s.getMonth() + 1).padStart(2, '0') + const d = String(s.getDate()).padStart(2, '0') + const pad2 = (n: number) => String(n).padStart(2, '0') + const hm = (t: Date) => `${pad2(t.getHours())}:${pad2(t.getMinutes())}` + if (s.toDateString() === e.toDateString()) { + const timePart = `${y}年${m}月${d}日 ${hm(s)}-${hm(e)}` + return name ? `${name} ${timePart}` : timePart + } + return [name, `${ad.session_start_at} ~ ${ad.session_end_at}`].filter(Boolean).join(' ') + } + return [name, ad.activity_date ? formatDateZh(ad.activity_date) : ''].filter(Boolean).join(' ') || '-' +} + +const COLS = [ + '活动', + '场馆', + '报名人', + '手机号', + '预约类型', + '预约场次', + '场次时间', + '状态', + '预约时间', + '核销时间', + '二维码 token', +] as const + +export function downloadActivityVerifyListXlsx(rows: ActivityVerifyExportRow[]) { + const exportRows = rows.map((r) => ({ + 活动: r.activity?.title ?? '', + 场馆: r.venue?.name ?? '', + 报名人: r.visitor_name ?? '', + 手机号: r.visitor_phone ?? '', + 预约类型: bookingTypeLabel(r.booking_type, r.ticket_count), + 预约场次: (((r.activity_day?.session_name ?? '') as string).trim() || '-') as string, + 场次时间: formatActivitySessionTime(r.activity_day ?? null), + 状态: reservationStatusLabel(r.status ?? ''), + 预约时间: formatDateTimeZh(r.created_at), + 核销时间: formatDateTimeZh(r.verified_at), + '二维码 token': r.qr_token ?? '', + })) + const ws = XLSX.utils.json_to_sheet(exportRows, { header: [...COLS] }) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '现场核销') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `现场核销-${dateText}.xlsx`) +} diff --git a/src/utils/exportTicketGrabListXlsx.ts b/src/utils/exportTicketGrabListXlsx.ts new file mode 100644 index 0000000..6d3860b --- /dev/null +++ b/src/utils/exportTicketGrabListXlsx.ts @@ -0,0 +1,192 @@ +import * as XLSX from 'xlsx' + +function str(v: unknown): string { + if (v == null || v === '') return '' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + return JSON.stringify(v) +} + +function num(v: unknown): string | number { + if (v === null || v === undefined || v === '') return '' + const n = Number(v) + return Number.isFinite(n) ? n : '' +} + +function stripHtml(html: unknown, max = 12000): string { + const s = String(html ?? '').trim() + if (!s) return '' + return s + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max) +} + +function fmtExportDate(v: unknown): string { + if (v == null || v === '') return '' + return String(v).slice(0, 10) +} + +function dailyReleaseRange(row: Record): string { + const a = String(row.daily_release_start_time ?? '').trim() + const b = String(row.daily_release_end_time ?? '').trim() + if (!a && !b) return '' + if (a && b) return `${a} ~ ${b}` + return a || b +} + +function bookingAudienceLabel(v: unknown): string { + const s = String(v ?? '').trim() + if (s === 'all') return '全部人员' + if (s === 'school_age') return '学龄内学生' + return s || '-' +} + +function shelfLabel(v: unknown): string { + if (v === true || v === 1 || v === '1') return '上架' + if (v === false || v === 0 || v === '0') return '下架' + return '-' +} + +function ageLimitLabel(row: Record): string { + const a = String(row.age_limit_start ?? '').trim().slice(0, 10) + const b = String(row.age_limit_end ?? '').trim().slice(0, 10) + if (!a && !b) return '不限制' + if (a && b) return `${a} ~ ${b}` + return a || b +} + +function tagsJoin(tags: unknown): string { + if (!Array.isArray(tags)) return '' + return tags.map((x) => String(x)).filter(Boolean).join('、') +} + +function galleryUrls(media: unknown): string { + if (!Array.isArray(media)) return '' + return media + .map((m) => { + if (m && typeof m === 'object' && 'url' in m) return String((m as { url: string }).url) + return '' + }) + .filter(Boolean) + .join('\n') +} + +type VenuePair = { name: string; quota: string | number } + +function parseVenues(row: Record): VenuePair[] { + const v = row.venues + if (!Array.isArray(v)) return [] + const out: VenuePair[] = [] + for (const item of v) { + if (!item || typeof item !== 'object') continue + const o = item as Record + const name = str(o.name) + const pivot = o.pivot + let quota: string | number = '' + if (pivot && typeof pivot === 'object' && !Array.isArray(pivot)) { + quota = num((pivot as Record).venue_total_quota) + } + out.push({ name, quota }) + } + return out +} + +/** 左则固定列(中文表头) */ +const BASE_COL_SPEC: { zh: string; format: (row: Record) => string | number }[] = [ + { zh: '名称', format: (r) => str(r.title) }, + { zh: '活动开始日期', format: (r) => fmtExportDate(r.start_at) }, + { zh: '活动结束日期', format: (r) => fmtExportDate(r.end_at) }, + { zh: '预约开始日期', format: (r) => fmtExportDate(r.booking_start_at) }, + { zh: '预约结束日期', format: (r) => fmtExportDate(r.booking_end_at) }, + { zh: '每日放票时间', format: dailyReleaseRange }, + { zh: '预约人群', format: (r) => bookingAudienceLabel(r.booking_audience) }, + { zh: '年龄限制', format: ageLimitLabel }, + { zh: '标签', format: (r) => tagsJoin(r.tags) }, + { zh: '上架状态', format: (r) => shelfLabel(r.is_active) }, + { zh: '封面图', format: (r) => str(r.cover_image) }, + { zh: '轮播图', format: (r) => galleryUrls(r.gallery_media) }, + { zh: '预约须知', format: (r) => stripHtml(r.reservation_notice, 8000) }, + { zh: '活动详情', format: (r) => stripHtml(r.detail_html) }, +] + +const VENUE_SUB_HEADERS = ['场馆', '放票总数'] as const +const SUB_COLS_PER_VENUE = VENUE_SUB_HEADERS.length + +const MAX_VENUE_EXPORT = 40 + +/** + * 抢票列表导出:全中文;右侧「参与场馆N」为二级表头,下列为「场馆」「放票总数」 + */ +export function downloadTicketGrabListXlsx(records: Record[]) { + const baseCols = BASE_COL_SPEC.length + + const maxVenues = Math.min( + MAX_VENUE_EXPORT, + Math.max(0, ...records.map((r) => parseVenues(r).length)), + ) + + const aoa: (string | number)[][] = [] + + if (maxVenues === 0) { + aoa.push(BASE_COL_SPEC.map((c) => c.zh)) + for (const row of records) { + aoa.push(BASE_COL_SPEC.map((spec) => spec.format(row))) + } + } else { + const row0: (string | number)[] = [...BASE_COL_SPEC.map((c) => c.zh)] + for (let i = 0; i < maxVenues; i += 1) { + row0.push(`参与场馆${i + 1}`) + for (let k = 1; k < SUB_COLS_PER_VENUE; k += 1) row0.push('') + } + + const row1: (string | number)[] = [...BASE_COL_SPEC.map(() => '')] + for (let i = 0; i < maxVenues; i += 1) { + row1.push(...VENUE_SUB_HEADERS) + } + + aoa.push(row0, row1) + + const merges: XLSX.Range[] = [] + for (let j = 0; j < baseCols; j += 1) { + merges.push({ s: { r: 0, c: j }, e: { r: 1, c: j } }) + } + for (let i = 0; i < maxVenues; i += 1) { + const c0 = baseCols + i * SUB_COLS_PER_VENUE + merges.push({ s: { r: 0, c: c0 }, e: { r: 0, c: c0 + SUB_COLS_PER_VENUE - 1 } }) + } + + for (const row of records) { + const vals = BASE_COL_SPEC.map((spec) => spec.format(row)) + const venues = parseVenues(row) + for (let i = 0; i < maxVenues; i += 1) { + const p = venues[i] + if (!p) { + vals.push('', '') + } else { + vals.push(p.name, p.quota) + } + } + aoa.push(vals) + } + + const ws = XLSX.utils.aoa_to_sheet(aoa) + ws['!merges'] = merges + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '抢票列表') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `抢票管理-${dateText}.xlsx`) + return + } + + const ws = XLSX.utils.aoa_to_sheet(aoa) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '抢票列表') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `抢票管理-${dateText}.xlsx`) +} diff --git a/src/utils/exportTicketGrabRegistrationsListXlsx.ts b/src/utils/exportTicketGrabRegistrationsListXlsx.ts new file mode 100644 index 0000000..f87e7c9 --- /dev/null +++ b/src/utils/exportTicketGrabRegistrationsListXlsx.ts @@ -0,0 +1,55 @@ +import * as XLSX from 'xlsx' +import { formatDateTimeZh, formatDateZh } from './datetime' +import { bookingTypeLabel } from './bookingType' +import { reservationStatusLabel } from './reservationStatus' + +export type TicketGrabRegistrationExportRow = { + visitor_name: string + visitor_phone?: string | null + id_card?: string | null + booking_type?: string | null + ticket_count?: number + status: 'pending' | 'verified' | 'cancelled' | 'expired' + qr_token: string + created_at: string + verified_at?: string | null + entry_date?: string | null + venue?: { id: number; name: string } | null + ticket_grab_event?: { id: number; title: string } | null +} + +const COLS = [ + '抢票活动', + '场馆', + '姓名', + '身份证', + '入馆日', + '预约类型', + '票数', + '状态', + '下单时间', + '核销时间', + '核销 Token', +] as const + +export function downloadTicketGrabRegistrationsListXlsx(rows: TicketGrabRegistrationExportRow[]) { + const exportRows = rows.map((r) => ({ + 抢票活动: r.ticket_grab_event?.title ?? '', + 场馆: r.venue?.name ?? '', + 姓名: r.visitor_name ?? '', + 身份证: r.id_card ?? '', + 入馆日: r.entry_date ? formatDateZh(String(r.entry_date)) : '-', + 预约类型: bookingTypeLabel(r.booking_type, r.ticket_count), + 票数: r.ticket_count ?? 1, + 状态: reservationStatusLabel(r.status), + 下单时间: formatDateTimeZh(r.created_at), + 核销时间: r.verified_at ? formatDateTimeZh(String(r.verified_at)) : '-', + '核销 Token': r.qr_token ?? '', + })) + const ws = XLSX.utils.json_to_sheet(exportRows, { header: [...COLS] }) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '抢票报名') + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `抢票报名-${dateText}.xlsx`) +} diff --git a/src/utils/exportXlsx.ts b/src/utils/exportXlsx.ts new file mode 100644 index 0000000..69f7ba1 --- /dev/null +++ b/src/utils/exportXlsx.ts @@ -0,0 +1,43 @@ +import * as XLSX from 'xlsx' + +export function cellValueForExport(v: unknown): string | number | boolean { + if (v === null || v === undefined) return '' + if (typeof v === 'number' && !Number.isFinite(v)) return '' + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v + return JSON.stringify(v) +} + +/** 将接口原始记录铺开为表格行:顶层键并列,值为对象或数组时用 JSON 字符串落格 */ +export function recordsToExportRows( + records: Record[], +): Record[] { + const keySet = new Set() + for (const row of records) { + if (!row || typeof row !== 'object' || Array.isArray(row)) continue + for (const k of Object.keys(row)) keySet.add(k) + } + const keys = [...keySet].sort((a, b) => a.localeCompare(b)) + return records.map((row) => { + const out: Record = {} + if (!row || typeof row !== 'object' || Array.isArray(row)) { + for (const k of keys) out[k] = '' + return out + } + const r = row as Record + for (const k of keys) { + out[k] = cellValueForExport(r[k]) + } + return out + }) +} + +export function downloadXlsxJsonRecords(filenameStem: string, sheetName: string, records: Record[]) { + const safeName = sheetName.replace(/[:\\/?*[\]]/g, '').slice(0, 31) || 'Sheet1' + const exportRows = recordsToExportRows(records) + const ws = XLSX.utils.json_to_sheet(exportRows) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, safeName) + const now = new Date() + const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + XLSX.writeFile(wb, `${filenameStem}-${dateText}.xlsx`) +} diff --git a/src/views/activities/ActivityList.vue b/src/views/activities/ActivityList.vue index 0c48b6d..daf0591 100644 --- a/src/views/activities/ActivityList.vue +++ b/src/views/activities/ActivityList.vue @@ -9,6 +9,7 @@ import { useModalDirtyGuard } from '../../composables/useModalDirtyGuard' import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard' import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits' import { listTableRowIndex } from '../../utils/listTableRowIndex' +import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx' type Venue = { id: number @@ -93,6 +94,55 @@ const filters = reactive({ audit_status: undefined as 'approved' | 'pending' | 'rejected' | undefined, }) +const exportActivitiesLoading = ref(false) + +function buildActivitiesListParams(page: number, pageSize: number) { + return { + page, + page_size: pageSize, + keyword: filters.keyword || undefined, + venue_id: filters.venue_id || undefined, + reservation_type: filters.reservation_type, + is_active: filters.is_active, + schedule_status: filters.schedule_status, + audit_status: filters.audit_status, + } +} + +async function exportActivitiesXlsx() { + if (exportActivitiesLoading.value) return + exportActivitiesLoading.value = true + try { + const pageSize = 100 + const all: Record[] = [] + let page = 1 + let total = Infinity + while (all.length < total) { + const { data } = await http.get('/activities', { + params: buildActivitiesListParams(page, pageSize), + }) + const chunk = (data?.data ?? []) as Record[] + total = Number(data?.total ?? 0) + all.push(...chunk) + if (chunk.length === 0) break + if (chunk.length < pageSize) break + page += 1 + if (page > 1000) break + } + if (all.length === 0) { + Message.warning('没有可导出的数据') + return + } + downloadActivityListXlsx(all) + Message.success('导出成功') + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } } + Message.error(err?.response?.data?.message ?? '导出失败') + } finally { + exportActivitiesLoading.value = false + } +} + const auditActivityVisible = ref(false) const auditActivityRecord = ref(null) const auditModalMode = ref<'audit' | 'view'>('view') @@ -1512,16 +1562,7 @@ async function loadAll() { try { const [aRes, vRes] = await Promise.all([ http.get('/activities', { - params: { - page: pagination.current, - page_size: pagination.pageSize, - keyword: filters.keyword || undefined, - venue_id: filters.venue_id || undefined, - reservation_type: filters.reservation_type, - is_active: filters.is_active, - schedule_status: filters.schedule_status, - audit_status: filters.audit_status, - }, + params: buildActivitiesListParams(pagination.current, pagination.pageSize), }), http.get('/venues'), ]) @@ -1985,6 +2026,7 @@ async function removeActivity(row: Activity) { 查询 新增活动 + 导出 ('pending') const keyword = ref('') const dateRange = ref([]) -const exportScope = ref<'current' | 'all'>('current') -const exportFields = ref([ - 'ID', - '活动', - '场馆', - '报名人', - '手机号', - '预约类型', - '预约票数', - '场次名称', - '活动时间', - '状态', - '下单时间', - '核销时间', - '二维码Token', -]) -const EXPORT_FIELDS_KEY = 'szkp_export_fields_registrations_v2' +const exportLoading = ref(false) const pagination = reactive({ current: 1, pageSize: 10, total: 0 }) const rows = ref([]) @@ -130,14 +114,13 @@ async function loadActivityOptions() { } } -function buildListParams(): Record { +/** 列表与导出共用:不含分页参数 */ +function activityRegistrationsQueryParams(): Record { const params: Record = { status: status.value, keyword: keyword.value || undefined, start_date: dateRange.value?.[0] || undefined, end_date: dateRange.value?.[1] || undefined, - page: pagination.current, - page_size: pagination.pageSize, } if (isSuperAdmin() && filterVenueId.value != null && filterVenueId.value > 0) { params.venue_id = filterVenueId.value @@ -148,6 +131,14 @@ function buildListParams(): Record { return params } +function buildListParams(): Record { + return { + ...activityRegistrationsQueryParams(), + page: pagination.current, + page_size: pagination.pageSize, + } +} + async function loadRows() { loading.value = true try { @@ -180,53 +171,37 @@ function registrationInactiveBodyCellStyle(record: unknown) { return {} } -async function exportCsv() { +async function exportRegistrationsXlsx() { + if (exportLoading.value) return + exportLoading.value = true try { - if (exportFields.value.length === 0) { - Message.warning('请至少选择一个导出字段') - return - } - let list: Registration[] = rows.value - if (exportScope.value === 'all') { - const res = await http.get('/activity-registrations', { - params: { - ...buildListParams(), - page: 1, - page_size: 5000, - }, + const pageSize = 200 + const all: Registration[] = [] + let page = 1 + let total = Infinity + while (all.length < total) { + const { data } = await http.get('/activity-registrations', { + params: { ...activityRegistrationsQueryParams(), page, page_size: pageSize }, }) - list = res.data.data || [] + const chunk = (data?.data ?? []) as Registration[] + total = Number(data?.total ?? 0) + all.push(...chunk) + if (chunk.length === 0) break + if (chunk.length < pageSize) break + page += 1 + if (page > 1000) break } - const fullRows = list.map((r) => ({ - ID: r.id, - 活动: r.activity?.title || '', - 场馆: r.venue?.name || '', - 报名人: r.visitor_name, - 手机号: r.visitor_phone || '', - 预约类型: bookingTypeLabel(r.booking_type, r.ticket_count), - 预约票数: r.ticket_count ?? 1, - 场次名称: (r.activity_day?.session_name || '').trim() || '-', - 活动时间: formatActivitySessionTime(r.activity_day), - 状态: reservationStatusLabel(r.status), - 下单时间: formatDateTimeZh(r.created_at), - 核销时间: formatDateTimeZh(r.verified_at), - 二维码Token: r.qr_token, - })) - const exportRows = fullRows.map((row) => { - const obj: Record = {} - exportFields.value.forEach((k) => { - obj[k] = row[k as keyof typeof row] - }) - return obj - }) - const ws = XLSX.utils.json_to_sheet(exportRows) - const wb = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(wb, ws, '报名管理') - const now = new Date() - const dateText = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` - XLSX.writeFile(wb, `报名管理-${dateText}.xlsx`) - } catch (error: any) { - Message.error(error?.response?.data?.message ?? '导出失败') + if (all.length === 0) { + Message.warning('没有可导出的数据') + return + } + downloadActivityRegistrationsListXlsx(all) + Message.success('导出成功') + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } } + Message.error(err?.response?.data?.message ?? '导出失败') + } finally { + exportLoading.value = false } } @@ -238,30 +213,11 @@ watch(filterVenueId, async () => { }) onMounted(async () => { - const cached = localStorage.getItem(EXPORT_FIELDS_KEY) - if (cached) { - try { - const arr = JSON.parse(cached) - if (Array.isArray(arr) && arr.length > 0) { - exportFields.value = arr.map((k: string) => (k === '入馆日期' ? '预约入馆日期' : k)) - } - } catch { - // ignore - } - } await loadMe() await loadVenuesAndActivities() await loadRows() }) -watch( - exportFields, - (val) => { - localStorage.setItem(EXPORT_FIELDS_KEY, JSON.stringify(val)) - }, - { deep: true }, -) - function onPageChange(p: number) { pagination.current = p loadRows() @@ -301,36 +257,8 @@ function onPageChange(p: number) { 查询 + 导出 -
- - 导出当前页 - 导出全部 - - - ID - 活动 - 场馆 - 报名人 - 手机号 - 预约类型 - 预约票数 - 场次名称 - 活动时间 - 状态 - 下单时间 - 核销时间 - 二维码Token - - 导出Excel -
([]) const listPagination = reactive({ current: 1, pageSize: 10 }) +const exportVerifyLoading = ref(false) + +function exportVerifyXlsx() { + if (exportVerifyLoading.value) return + exportVerifyLoading.value = true + try { + if (rows.value.length === 0) { + Message.warning('没有可导出的数据') + return + } + downloadActivityVerifyListXlsx(rows.value) + Message.success('导出成功') + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } } } + Message.error(err?.response?.data?.message ?? '导出失败') + } finally { + exportVerifyLoading.value = false + } +} async function loadRows() { loading.value = true @@ -135,6 +155,7 @@ onMounted(loadRows) 查询 + 导出 diff --git a/src/views/ticket-grab/TicketGrabList.vue b/src/views/ticket-grab/TicketGrabList.vue index dcce02c..47fd379 100644 --- a/src/views/ticket-grab/TicketGrabList.vue +++ b/src/views/ticket-grab/TicketGrabList.vue @@ -6,6 +6,7 @@ import { http } from '../../api/http' import { buildUnifiedActivityVerifyLoginUrl } from '../../api/h5Http' import { listTableRowIndex } from '../../utils/listTableRowIndex' import { resolvePublicMediaUrl } from '../../utils/mediaUrl' +import { downloadTicketGrabListXlsx } from '../../utils/exportTicketGrabListXlsx' /** 与本表列宽匹配;勿用全局 LIST_TABLE_SCROLL_X(3220),否则列会被撑宽 */ const TICKET_GRAB_LIST_SCROLL_X = 1418 @@ -68,6 +69,8 @@ const keyword = ref('') const filterVenueId = ref(undefined) const filterOnShelf = ref(undefined) +const exportTicketGrabLoading = ref(false) + const visible = ref(false) const saving = ref(false) const editId = ref(null) @@ -774,20 +777,20 @@ async function loadVenues() { venues.value = Array.isArray(list) ? (list as VenueFull[]) : [] } +function buildTgListParams(page: number, pageSize: number): Record { + const params: Record = { page, page_size: pageSize } + const kw = keyword.value.trim() + if (kw) params.keyword = kw + if (filterVenueId.value != null && filterVenueId.value > 0) params.venue_id = filterVenueId.value + if (filterOnShelf.value === '0' || filterOnShelf.value === '1') params.is_active = filterOnShelf.value + return params +} + async function loadRows() { loading.value = true try { - const params: Record = { - page: pagination.current, - page_size: pagination.pageSize, - } - const kw = keyword.value.trim() - if (kw) params.keyword = kw - if (filterVenueId.value != null && filterVenueId.value > 0) params.venue_id = filterVenueId.value - if (filterOnShelf.value === '0' || filterOnShelf.value === '1') params.is_active = filterOnShelf.value - const { data } = await http.get('/ticket-grab-events', { - params, + params: buildTgListParams(pagination.current, pagination.pageSize), }) rows.value = data.data pagination.total = data.total @@ -798,6 +801,40 @@ async function loadRows() { } } +async function exportTicketGrabEventsXlsx() { + if (exportTicketGrabLoading.value) return + exportTicketGrabLoading.value = true + try { + const pageSize = 100 + const all: Record[] = [] + let page = 1 + let total = Infinity + while (all.length < total) { + const { data } = await http.get('/ticket-grab-events', { + params: buildTgListParams(page, pageSize), + }) + const chunk = (data?.data ?? []) as Record[] + total = Number(data?.total ?? 0) + all.push(...chunk) + if (chunk.length === 0) break + if (chunk.length < pageSize) break + page += 1 + if (page > 1000) break + } + if (all.length === 0) { + Message.warning('没有可导出的数据') + return + } + downloadTicketGrabListXlsx(all) + Message.success('导出成功') + } catch (e: unknown) { + const err = e as { response?: { data?: { message?: string } } } + Message.error(err?.response?.data?.message ?? '导出失败') + } finally { + exportTicketGrabLoading.value = false + } +} + function openCreate() { editId.value = null resetForm() @@ -1202,6 +1239,7 @@ onMounted(async () => { 查询 新建抢票 + 导出 ([]) const pagination = reactive({ current: 1, pageSize: 10, total: 0 }) const rows = ref([]) const ticketGrabEvents = ref([]) +const exportTgRegsLoading = ref(false) type CurrentUser = { role?: string; full_admin_access?: boolean } type VenueMini = { id: number; name: string } @@ -76,23 +78,29 @@ async function loadTicketGrabEvents() { } } +function buildTicketGrabRegistrationsParams(page: number, pageSize: number): Record { + const params: Record = { + reservation_kind: 'ticket_grab', + ticket_grab_event_id: ticketGrabEventId.value || undefined, + status: status.value, + keyword: keyword.value || undefined, + start_date: dateRange.value?.[0] || undefined, + end_date: dateRange.value?.[1] || undefined, + page, + page_size: pageSize, + } + if (isSuperAdmin() && filterVenueId.value != null && filterVenueId.value > 0) { + params.venue_id = filterVenueId.value + } + return params +} + async function loadRows() { loading.value = true try { - const params: Record = { - reservation_kind: 'ticket_grab', - ticket_grab_event_id: ticketGrabEventId.value || undefined, - status: status.value, - keyword: keyword.value || undefined, - start_date: dateRange.value?.[0] || undefined, - end_date: dateRange.value?.[1] || undefined, - page: pagination.current, - page_size: pagination.pageSize, - } - if (isSuperAdmin() && filterVenueId.value != null && filterVenueId.value > 0) { - params.venue_id = filterVenueId.value - } - const { data } = await http.get('/activity-registrations', { params }) + const { data } = await http.get('/activity-registrations', { + params: buildTicketGrabRegistrationsParams(pagination.current, pagination.pageSize), + }) rows.value = data.data pagination.total = data.total } catch (e: any) { @@ -102,6 +110,40 @@ async function loadRows() { } } +async function exportTicketGrabRegistrationsXlsx() { + if (exportTgRegsLoading.value) return + exportTgRegsLoading.value = true + try { + const pageSize = 200 + const all: Row[] = [] + let page = 1 + let total = Infinity + while (all.length < total) { + const { data } = await http.get('/activity-registrations', { + params: buildTicketGrabRegistrationsParams(page, pageSize), + }) + const chunk = (data?.data ?? []) as Row[] + total = Number(data?.total ?? 0) + all.push(...chunk) + if (chunk.length === 0) break + if (chunk.length < pageSize) break + page += 1 + if (page > 1000) break + } + if (all.length === 0) { + Message.warning('没有可导出的数据') + return + } + downloadTicketGrabRegistrationsListXlsx(all) + Message.success('导出成功') + } catch (e: unknown) { + const err = e as { response?: { data?: { message?: string } } } + Message.error(err?.response?.data?.message ?? '导出失败') + } finally { + exportTgRegsLoading.value = false + } +} + function onPageChange(cur: number) { pagination.current = cur void loadRows() @@ -188,6 +230,7 @@ onMounted(async () => { > 查询 + 导出