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.

247 lines
9.1 KiB

4 months ago
<script setup lang="ts">
import { onMounted, reactive, ref } 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 { listTableRowIndex } from '../../utils/listTableRowIndex'
import { reservationStatusLabel } from '../../utils/reservationStatus'
3 months ago
import { downloadActivityVerifyListXlsx } from '../../utils/exportActivityVerifyListXlsx'
4 months ago
3 months ago
const ACTIVITY_VERIFY_LIST_SCROLL_X = 2140
4 months ago
type ActivityDayRef = {
id: number
activity_id: number
activity_date: string
session_name?: string
session_start_at?: string
session_end_at?: string
time_range_text?: string
}
type Reservation = {
id: number
venue_id: number
visitor_name: string
visitor_phone?: string
booking_type?: string | null
ticket_count?: number
qr_token: string
status: 'pending' | 'verified' | 'cancelled' | 'expired'
created_at?: string
verified_at?: string | null
venue?: { id: number; name: string }
activity?: { id: number; title: string }
activity_day?: ActivityDayRef | null
}
const loading = ref(false)
const rows = ref<Reservation[]>([])
const tokenInput = ref('')
const verifying = ref(false)
function formatActivitySessionTime(ad: ActivityDayRef | null | undefined): string {
if (!ad) return '-'
const tr = (ad.time_range_text || '').trim()
if (tr) return tr
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 statusFilter = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('all')
const keyword = ref('')
const dateRange = ref<string[]>([])
const listPagination = reactive({ current: 1, pageSize: 10 })
3 months ago
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
}
}
4 months ago
async function loadRows() {
loading.value = true
try {
const { data } = await http.get('/reservations', {
params: {
status: statusFilter.value,
keyword: keyword.value || undefined,
start_date: dateRange.value?.[0] || undefined,
end_date: dateRange.value?.[1] || undefined,
date_field: 'activity_day',
4 months ago
reservation_kind: 'activity',
},
})
rows.value = data
listPagination.current = 1
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '加载预约列表失败')
} finally {
loading.value = false
}
}
function onSearchList() {
void loadRows()
}
async function verifyToken() {
if (!tokenInput.value) {
Message.warning('请输入二维码 token')
return
}
verifying.value = true
try {
await http.post('/reservations/verify', { qr_token: tokenInput.value })
Message.success('核销成功')
3 months ago
window.location.reload()
4 months ago
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '核销失败')
} finally {
verifying.value = false
}
}
onMounted(loadRows)
</script>
<template>
<a-card title="活动管理 / 现场核销">
<a-space direction="vertical" fill>
<a-space wrap :size="12">
<a-input v-model="tokenInput" style="width: min(100%, 420px)" placeholder="请输入二维码 token" allow-clear />
<a-button type="primary" :loading="verifying" @click="verifyToken"></a-button>
</a-space>
<div class="verify-list-toolbar">
<a-space wrap :size="12">
<a-radio-group v-model="statusFilter" 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-input v-model="keyword" placeholder="报名人/手机/token" allow-clear style="width: 220px" />
<span class="verify-filter-label">场次日期</span>
4 months ago
<a-range-picker v-model="dateRange" style="width: 260px" />
<a-button type="primary" @click="onSearchList"></a-button>
3 months ago
<a-button :loading="exportVerifyLoading" @click="exportVerifyXlsx"></a-button>
4 months ago
</a-space>
</div>
<a-table
class="list-data-table verify-table"
:scroll="{ x: ACTIVITY_VERIFY_LIST_SCROLL_X }"
:data="rows"
:loading="loading"
row-key="id"
:pagination="{
current: listPagination.current,
pageSize: listPagination.pageSize,
total: rows.length,
showTotal: true,
}"
@page-change="(p: number) => (listPagination.current = p)"
>
<template #columns>
<a-table-column title="" :width="50" :ellipsis="true" :tooltip="true">
<template #cell="{ rowIndex }">{{
listTableRowIndex(rowIndex, listPagination.current, listPagination.pageSize)
}}</template>
</a-table-column>
<a-table-column title="活动" :width="240" :min-width="180" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ record.activity?.title ?? '-' }}</template>
</a-table-column>
<a-table-column title="场馆" :width="200" :min-width="160" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ record.venue?.name ?? '-' }}</template>
</a-table-column>
<a-table-column title="报名人" data-index="visitor_name" :width="120" :ellipsis="true" :tooltip="true" />
<a-table-column title="手机号" data-index="visitor_phone" :width="130" :ellipsis="true" :tooltip="true" />
<a-table-column title="预约类型" :width="100">
<template #cell="{ record }">{{ bookingTypeLabel(record.booking_type, record.ticket_count) }}</template>
</a-table-column>
<a-table-column title="预约场次" :width="160" :ellipsis="true" :tooltip="true">
<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">
<template #cell="{ record }">{{ formatActivitySessionTime(record.activity_day) }}</template>
</a-table-column>
<a-table-column title="状态" :width="100">
<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="175" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ formatDateTimeZh(record.created_at) }}</template>
</a-table-column>
<a-table-column title="核销时间" :width="175" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ formatDateTimeZh(record.verified_at) }}</template>
</a-table-column>
<a-table-column
title="二维码 token"
data-index="qr_token"
3 months ago
:width="360"
:min-width="280"
4 months ago
:ellipsis="true"
:tooltip="true"
fixed="right"
align="left"
/>
</template>
</a-table>
</a-space>
</a-card>
</template>
<style scoped>
.verify-list-toolbar {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
</style>