master
lion 1 month ago
parent d11f282e4a
commit 1725b16a41

@ -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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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, unknown>): 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, unknown>) => 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<string, unknown>): Record<string, unknown>[] {
const v = row.activity_days
if (!Array.isArray(v)) return []
return v.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) as Record<string, unknown>[]
}
const MAX_SESSION_EXPORT = 40
/** 场次单元格格式化 */
function fmtSessionCell(key: string, d: Record<string, unknown>): 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<string, unknown>[]) {
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`)
}

@ -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`)
}

@ -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`)
}

@ -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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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, unknown>): 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, unknown>): 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<string, unknown>): 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<string, unknown>
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<string, unknown>).venue_total_quota)
}
out.push({ name, quota })
}
return out
}
/** 左则固定列(中文表头) */
const BASE_COL_SPEC: { zh: string; format: (row: Record<string, unknown>) => 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<string, unknown>[]) {
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`)
}

@ -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`)
}

@ -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<string, unknown>[],
): Record<string, string | number | boolean>[] {
const keySet = new Set<string>()
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<string, string | number | boolean> = {}
if (!row || typeof row !== 'object' || Array.isArray(row)) {
for (const k of keys) out[k] = ''
return out
}
const r = row as Record<string, unknown>
for (const k of keys) {
out[k] = cellValueForExport(r[k])
}
return out
})
}
export function downloadXlsxJsonRecords(filenameStem: string, sheetName: string, records: Record<string, unknown>[]) {
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`)
}

@ -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<string, unknown>[] = []
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<string, unknown>[]
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<Activity | null>(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) {
</a-select>
<a-button type="primary" @click="onSearch"></a-button>
<a-button type="primary" @click="openCreate"></a-button>
<a-button :loading="exportActivitiesLoading" @click="exportActivitiesXlsx"></a-button>
</a-space>
<a-table

@ -6,7 +6,7 @@ import { formatDateTimeZh, formatDateZh } from '../../utils/datetime'
import { bookingTypeLabel } from '../../utils/bookingType'
import { reservationStatusLabel } from '../../utils/reservationStatus'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import * as XLSX from 'xlsx'
import { downloadActivityRegistrationsListXlsx } from '../../utils/exportActivityRegistrationsListXlsx'
const REGISTRATIONS_LIST_SCROLL_X = 2100
@ -61,23 +61,7 @@ const loading = ref(false)
const status = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('pending')
const keyword = ref('')
const dateRange = ref<string[]>([])
const exportScope = ref<'current' | 'all'>('current')
const exportFields = ref<string[]>([
'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<Registration[]>([])
@ -130,14 +114,13 @@ async function loadActivityOptions() {
}
}
function buildListParams(): Record<string, unknown> {
/** 列表与导出共用:不含分页参数 */
function activityRegistrationsQueryParams(): Record<string, unknown> {
const params: Record<string, unknown> = {
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<string, unknown> {
return params
}
function buildListParams(): Record<string, unknown> {
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<string, any> = {}
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) {
<a-input v-model="keyword" placeholder="姓名/手机/token" allow-clear style="width: 220px" />
<a-range-picker v-model="dateRange" style="width: 260px" />
<a-button type="primary" @click="onSearch"></a-button>
<a-button :loading="exportLoading" @click="exportRegistrationsXlsx"></a-button>
</a-space>
<div class="reg-export-bar">
<a-select v-model="exportScope" class="reg-export-scope">
<a-option value="current">导出当前页</a-option>
<a-option value="all">导出全部</a-option>
</a-select>
<a-select
v-model="exportFields"
multiple
allow-clear
:max-tag-count="2"
placeholder="选择导出字段"
class="reg-export-fields"
>
<a-option value="ID">ID</a-option>
<a-option value="活动">活动</a-option>
<a-option value="场馆">场馆</a-option>
<a-option value="报名人">报名人</a-option>
<a-option value="手机号">手机号</a-option>
<a-option value="预约类型">预约类型</a-option>
<a-option value="预约票数">预约票数</a-option>
<a-option value="场次名称">场次名称</a-option>
<a-option value="活动时间">活动时间</a-option>
<a-option value="状态">状态</a-option>
<a-option value="下单时间">下单时间</a-option>
<a-option value="核销时间">核销时间</a-option>
<a-option value="二维码Token">二维码Token</a-option>
</a-select>
<a-button class="reg-export-btn" @click="exportCsv">Excel</a-button>
</div>
</div>
<a-table
class="list-data-table registrations-table"
@ -432,56 +360,6 @@ function onPageChange(p: number) {
box-sizing: border-box;
}
/* 导出:中间列可收缩,窄屏时按钮自动落到下一行 */
.reg-export-bar {
display: grid;
grid-template-columns: 130px minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
margin-top: 12px;
width: 100%;
box-sizing: border-box;
}
.reg-export-scope {
width: 130px;
}
.reg-export-fields {
width: 100%;
min-width: 0;
}
.reg-export-fields :deep(.arco-select-view) {
max-width: 100%;
}
.reg-export-btn {
justify-self: start;
}
@media (max-width: 720px) {
.reg-export-bar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'scope btn'
'fields fields';
}
.reg-export-scope {
grid-area: scope;
}
.reg-export-fields {
grid-area: fields;
}
.reg-export-btn {
grid-area: btn;
justify-self: end;
}
}
.registrations-table :deep(.arco-table-td .arco-table-cell) {
white-space: normal;
word-break: break-word;

@ -6,6 +6,7 @@ import { formatDateTimeZh, formatDateZh } from '../../utils/datetime'
import { bookingTypeLabel } from '../../utils/bookingType'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { reservationStatusLabel } from '../../utils/reservationStatus'
import { downloadActivityVerifyListXlsx } from '../../utils/exportActivityVerifyListXlsx'
const ACTIVITY_VERIFY_LIST_SCROLL_X = 2140
@ -69,6 +70,25 @@ const statusFilter = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired
const keyword = ref('')
const dateRange = ref<string[]>([])
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)
<a-input v-model="keyword" placeholder="报名人/手机/token" allow-clear style="width: 220px" />
<a-range-picker v-model="dateRange" style="width: 260px" />
<a-button type="primary" @click="onSearchList"></a-button>
<a-button :loading="exportVerifyLoading" @click="exportVerifyXlsx"></a-button>
</a-space>
</div>

@ -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<number | undefined>(undefined)
const filterOnShelf = ref<string | undefined>(undefined)
const exportTicketGrabLoading = ref(false)
const visible = ref(false)
const saving = ref(false)
const editId = ref<number | null>(null)
@ -774,20 +777,20 @@ async function loadVenues() {
venues.value = Array.isArray(list) ? (list as VenueFull[]) : []
}
function buildTgListParams(page: number, pageSize: number): Record<string, string | number> {
const params: Record<string, string | number> = { 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<string, string | number> = {
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<string, unknown>[] = []
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<string, unknown>[]
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 () => {
</a-select>
<a-button type="primary" @click="() => ((pagination.current = 1), loadRows())">查询</a-button>
<a-button type="primary" @click="openCreate"></a-button>
<a-button :loading="exportTicketGrabLoading" @click="exportTicketGrabEventsXlsx"></a-button>
</a-space>
<a-table

@ -6,6 +6,7 @@ import { formatDateTimeZh, formatDateZh } from '../../utils/datetime'
import { bookingTypeLabel } from '../../utils/bookingType'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { reservationStatusLabel } from '../../utils/reservationStatus'
import { downloadTicketGrabRegistrationsListXlsx } from '../../utils/exportTicketGrabRegistrationsListXlsx'
const TG_REGISTRATIONS_LIST_SCROLL_X = 1680
@ -35,6 +36,7 @@ const dateRange = ref<string[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const rows = ref<Row[]>([])
const ticketGrabEvents = ref<TicketGrabEventOption[]>([])
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<string, unknown> {
const params: Record<string, unknown> = {
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<string, unknown> = {
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 () => {
>
查询
</a-button>
<a-button :loading="exportTgRegsLoading" @click="exportTicketGrabRegistrationsXlsx"></a-button>
</a-space>
<a-table

Loading…
Cancel
Save