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