You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

504 lines
17 KiB

<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { http } from '../../api/http'
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'
const REGISTRATIONS_LIST_SCROLL_X = 2100
type ActivityDayRef = {
id: number
activity_id: number
activity_date: string
session_name?: string
session_start_at?: string
session_end_at?: string
}
type Registration = {
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?: ActivityDayRef | null
}
function formatActivitySessionTime(ad: ActivityDayRef | null | undefined): string {
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 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 pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const rows = ref<Registration[]>([])
type CurrentUser = { role?: string; full_admin_access?: boolean }
type VenueMini = { id: number; name: string }
const currentUser = ref<CurrentUser | null>(null)
const venuesList = ref<VenueMini[]>([])
const filterVenueId = ref<number | undefined>(undefined)
const filterActivityId = ref<number | undefined>(undefined)
const activityOptions = ref<{ label: string; value: number }[]>([])
function isSuperAdmin() {
return currentUser.value?.full_admin_access === true
}
async function loadMe() {
try {
const { data } = await http.get('/me')
currentUser.value = data
} catch {
currentUser.value = null
}
}
async function loadVenuesAndActivities() {
try {
const vRes = await http.get('/venues')
venuesList.value = Array.isArray(vRes.data) ? vRes.data : []
} catch {
venuesList.value = []
}
await loadActivityOptions()
}
async function loadActivityOptions() {
try {
const params: Record<string, unknown> = { page: 1, page_size: 500 }
if (isSuperAdmin() && filterVenueId.value != null && filterVenueId.value > 0) {
params.venue_id = filterVenueId.value
}
const { data } = await http.get('/activities', { params })
const list = data?.data ?? []
activityOptions.value = list.map((a: { id: number; title: string }) => ({
label: a.title,
value: a.id,
}))
} catch {
activityOptions.value = []
}
}
function buildListParams(): 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
}
if (filterActivityId.value != null && filterActivityId.value > 0) {
params.activity_id = filterActivityId.value
}
return params
}
async function loadRows() {
loading.value = true
try {
const { data } = await http.get('/activity-registrations', { params: buildListParams() })
rows.value = data.data
pagination.total = data.total
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '加载报名失败')
} finally {
loading.value = false
}
}
function onSearch() {
pagination.current = 1
loadRows()
}
/** Arco Table 行级 row-class 无效;已取消 / 已过期 用 cell class + inline 背景兜底 */
function registrationInactiveBodyCellClass(record: unknown) {
const st = String((record as { status?: string })?.status ?? '').trim()
return st === 'cancelled' || st === 'expired' ? 'reg-row-inactive-cell' : undefined
}
function registrationInactiveBodyCellStyle(record: unknown) {
const st = String((record as { status?: string })?.status ?? '').trim()
if (st === 'cancelled' || st === 'expired') {
return { backgroundColor: 'var(--color-fill-2)' }
}
return {}
}
async function exportCsv() {
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,
},
})
list = res.data.data || []
}
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 ?? '导出失败')
}
}
watch(filterVenueId, async () => {
filterActivityId.value = undefined
await loadActivityOptions()
pagination.current = 1
void loadRows()
})
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()
}
</script>
<template>
<a-card title="活动管理 / 报名管理">
<div class="reg-toolbar">
<a-space wrap :size="12">
<a-radio-group v-model="status" type="button" size="small" @change="loadRows">
<a-radio value="all">全部</a-radio>
<a-radio value="pending">待核销</a-radio>
<a-radio value="verified">已核销</a-radio>
<a-radio value="cancelled">已取消</a-radio>
<a-radio value="expired">已过期</a-radio>
</a-radio-group>
<a-select
v-if="isSuperAdmin()"
v-model="filterVenueId"
allow-clear
placeholder="全部场馆"
style="width: 200px"
>
<a-option v-for="v in venuesList" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
<a-select
v-model="filterActivityId"
allow-clear
allow-search
placeholder="全部活动"
style="width: 260px"
@change="onSearch"
>
<a-option v-for="opt in activityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</a-option>
</a-select>
<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-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"
:scroll="{ x: REGISTRATIONS_LIST_SCROLL_X }"
:data="rows"
:loading="loading"
row-key="id"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showTotal: true,
}"
@page-change="onPageChange"
>
<template #columns>
<a-table-column title="" :width="50" :ellipsis="true" :tooltip="true" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ rowIndex }">{{
listTableRowIndex(rowIndex, pagination.current, pagination.pageSize)
}}</template>
</a-table-column>
<a-table-column title="活动" :width="280" :min-width="220" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ record.activity?.title || '-' }}</template>
</a-table-column>
<a-table-column title="场馆" :width="220" :min-width="180" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ record.venue?.name || '-' }}</template>
</a-table-column>
<a-table-column title="报名人" data-index="visitor_name" :width="140" :min-width="120" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle" />
<a-table-column title="手机号" data-index="visitor_phone" :width="150" :min-width="130" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle" />
<a-table-column title="预约类型" :width="100" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ bookingTypeLabel(record.booking_type, record.ticket_count) }}</template>
</a-table-column>
<a-table-column title="预约票数" :width="110" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ record.ticket_count ?? 1 }}</template>
</a-table-column>
<a-table-column title="场次名称" :width="160" :min-width="120" :ellipsis="true" :tooltip="true" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ (record.activity_day?.session_name || '').trim() || '-' }}</template>
</a-table-column>
<a-table-column title="活动时间" :width="220" :min-width="180" :ellipsis="true" :tooltip="true" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ formatActivitySessionTime(record.activity_day) }}</template>
</a-table-column>
<a-table-column title="状态" :width="120" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">
<a-tag
:color="
record.status === 'verified'
? 'green'
: record.status === 'pending'
? 'arcoblue'
: record.status === 'expired'
? 'orange'
: 'gray'
"
>
{{ reservationStatusLabel(record.status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="下单时间" :width="190" :min-width="175" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ formatDateTimeZh(record.created_at) }}</template>
</a-table-column>
<a-table-column title="核销时间" :width="190" :min-width="175" :body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle">
<template #cell="{ record }">{{ formatDateTimeZh(record.verified_at) }}</template>
</a-table-column>
<a-table-column
title="二维码 token"
data-index="qr_token"
:width="360"
:min-width="280"
:ellipsis="true"
:tooltip="true"
fixed="right"
align="left"
:body-cell-class="registrationInactiveBodyCellClass"
:body-cell-style="registrationInactiveBodyCellStyle"
/>
</template>
</a-table>
</a-card>
</template>
<style scoped>
.reg-toolbar {
width: 100%;
max-width: 100%;
margin-bottom: 12px;
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;
}
/* token 列启用省略号时单行展示 */
.registrations-table :deep(.arco-table-text-ellipsis) {
white-space: nowrap;
}
.registrations-table :deep(.arco-table-td.reg-row-inactive-cell) {
background-color: var(--color-fill-2) !important;
}
.registrations-table :deep(.arco-table-tr:hover .arco-table-td.reg-row-inactive-cell) {
background-color: var(--color-fill-3) !important;
}
</style>