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.
370 lines
13 KiB
370 lines
13 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 { listTableRowIndex } from '../../utils/listTableRowIndex'
|
|
import { reservationStatusLabel } from '../../utils/reservationStatus'
|
|
import { downloadActivityVerifyListXlsx } from '../../utils/exportActivityVerifyListXlsx'
|
|
|
|
const ACTIVITY_VERIFY_LIST_SCROLL_X = 2240
|
|
|
|
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
|
|
}
|
|
|
|
type CurrentUser = { role?: string; full_admin_access?: boolean }
|
|
type VenueMini = { id: number; name: string }
|
|
|
|
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 })
|
|
const exportVerifyLoading = ref(false)
|
|
|
|
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 }[]>([])
|
|
const activitySearchLoading = ref(false)
|
|
let activitySearchTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function isVenueAdmin() {
|
|
return currentUser.value?.role === 'venue_admin'
|
|
}
|
|
|
|
/** 超管 / 平台管理员(非场馆管理员)可见场馆、活动筛选 */
|
|
function canFilterByVenueAndActivity() {
|
|
if (!currentUser.value) return false
|
|
return !isVenueAdmin()
|
|
}
|
|
|
|
async function loadMe() {
|
|
try {
|
|
const { data } = await http.get('/me')
|
|
currentUser.value = data as CurrentUser
|
|
} catch {
|
|
currentUser.value = null
|
|
}
|
|
}
|
|
|
|
async function loadVenues() {
|
|
if (!canFilterByVenueAndActivity()) {
|
|
venuesList.value = []
|
|
return
|
|
}
|
|
try {
|
|
const { data } = await http.get('/venues')
|
|
venuesList.value = Array.isArray(data) ? (data as VenueMini[]) : []
|
|
} catch {
|
|
venuesList.value = []
|
|
}
|
|
}
|
|
|
|
async function loadActivityOptions(keywordText = '') {
|
|
if (!canFilterByVenueAndActivity()) {
|
|
activityOptions.value = []
|
|
return
|
|
}
|
|
activitySearchLoading.value = true
|
|
try {
|
|
const params: Record<string, unknown> = { limit: 500 }
|
|
const kw = keywordText.trim()
|
|
if (kw) params.keyword = kw
|
|
if (filterVenueId.value != null && filterVenueId.value > 0) {
|
|
params.venue_id = filterVenueId.value
|
|
}
|
|
const { data } = await http.get('/activities/options', { params })
|
|
const list = (data?.data ?? []) as { id: number; title: string }[]
|
|
const mapped = list.map((a) => ({ label: a.title, value: a.id }))
|
|
const selectedId = filterActivityId.value
|
|
if (selectedId != null && selectedId > 0 && !mapped.some((o) => o.value === selectedId)) {
|
|
const prev = activityOptions.value.find((o) => o.value === selectedId)
|
|
if (prev) mapped.unshift(prev)
|
|
}
|
|
activityOptions.value = mapped
|
|
} catch {
|
|
activityOptions.value = []
|
|
} finally {
|
|
activitySearchLoading.value = false
|
|
}
|
|
}
|
|
|
|
function onActivitySelectSearch(value: string) {
|
|
if (activitySearchTimer) clearTimeout(activitySearchTimer)
|
|
activitySearchTimer = setTimeout(() => {
|
|
void loadActivityOptions(value)
|
|
}, 300)
|
|
}
|
|
|
|
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
|
|
try {
|
|
const params: Record<string, unknown> = {
|
|
status: statusFilter.value,
|
|
keyword: keyword.value || undefined,
|
|
start_date: dateRange.value?.[0] || undefined,
|
|
end_date: dateRange.value?.[1] || undefined,
|
|
date_field: 'activity_day',
|
|
reservation_kind: 'activity',
|
|
}
|
|
if (canFilterByVenueAndActivity() && filterVenueId.value != null && filterVenueId.value > 0) {
|
|
params.venue_id = filterVenueId.value
|
|
}
|
|
if (canFilterByVenueAndActivity() && filterActivityId.value != null && filterActivityId.value > 0) {
|
|
params.activity_id = filterActivityId.value
|
|
}
|
|
const { data } = await http.get('/reservations', { params })
|
|
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('核销成功')
|
|
window.location.reload()
|
|
} catch (error: any) {
|
|
Message.error(error?.response?.data?.message ?? '核销失败')
|
|
} finally {
|
|
verifying.value = false
|
|
}
|
|
}
|
|
|
|
watch(filterVenueId, async () => {
|
|
filterActivityId.value = undefined
|
|
await loadActivityOptions()
|
|
void loadRows()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await loadMe()
|
|
await loadVenues()
|
|
await loadActivityOptions()
|
|
await 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-select
|
|
v-if="canFilterByVenueAndActivity()"
|
|
v-model="filterVenueId"
|
|
allow-clear
|
|
allow-search
|
|
placeholder="搜索或选择场馆"
|
|
style="width: 220px"
|
|
>
|
|
<a-option v-for="v in venuesList" :key="v.id" :value="v.id">{{ v.name }}</a-option>
|
|
</a-select>
|
|
<a-select
|
|
v-if="canFilterByVenueAndActivity()"
|
|
v-model="filterActivityId"
|
|
allow-clear
|
|
allow-search
|
|
:filter-option="false"
|
|
:loading="activitySearchLoading"
|
|
placeholder="搜索或选择活动"
|
|
style="width: 280px"
|
|
@search="onActivitySelectSearch"
|
|
@change="onSearchList"
|
|
@clear="() => loadActivityOptions()"
|
|
>
|
|
<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" />
|
|
<span class="verify-filter-label">场次日期</span>
|
|
<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>
|
|
|
|
<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="100">
|
|
<template #cell="{ record }">{{ record.ticket_count ?? 1 }}</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"
|
|
:width="360"
|
|
:min-width="280"
|
|
: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>
|