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.
459 lines
15 KiB
459 lines
15 KiB
|
3 months ago
|
<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
|
||
|
|
is_blacklisted?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
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'>('all')
|
||
|
|
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[]>([])
|
||
|
|
const selectedKeys = ref<(number | string)[]>([])
|
||
|
|
const blacklistVisible = ref(false)
|
||
|
|
const blacklistReason = ref('')
|
||
|
|
const blacklistMode = ref<'single' | 'batch'>('single')
|
||
|
|
const currentRow = ref<Registration | null>(null)
|
||
|
|
|
||
|
|
async function loadRows() {
|
||
|
|
loading.value = true
|
||
|
|
try {
|
||
|
|
const params = {
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
const { data } = await http.get('/activity-registrations', { params })
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
|
||
|
|
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: {
|
||
|
|
status: status.value,
|
||
|
|
keyword: keyword.value || undefined,
|
||
|
|
start_date: dateRange.value?.[0] || undefined,
|
||
|
|
end_date: dateRange.value?.[1] || undefined,
|
||
|
|
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 ?? '导出失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function openQuickBlacklist(row: Registration) {
|
||
|
|
blacklistMode.value = 'single'
|
||
|
|
currentRow.value = row
|
||
|
|
blacklistReason.value = ''
|
||
|
|
blacklistVisible.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function openBatchQuickBlacklist() {
|
||
|
|
if (!selectedKeys.value.length) {
|
||
|
|
Message.warning('请先选择要列入灰名单的报名记录')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
blacklistMode.value = 'batch'
|
||
|
|
currentRow.value = null
|
||
|
|
blacklistReason.value = ''
|
||
|
|
blacklistVisible.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submitQuickBlacklist() {
|
||
|
|
if (!blacklistReason.value.trim()) {
|
||
|
|
Message.warning('请填写灰名单原因')
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
if (blacklistMode.value === 'single' && currentRow.value) {
|
||
|
|
await http.post(`/activity-registrations/${currentRow.value.id}/quick-blacklist`, {
|
||
|
|
reason: blacklistReason.value.trim(),
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
await http.post('/activity-registrations/quick-blacklist/batch', {
|
||
|
|
reservation_ids: selectedKeys.value.map((id) => Number(id)),
|
||
|
|
reason: blacklistReason.value.trim(),
|
||
|
|
})
|
||
|
|
selectedKeys.value = []
|
||
|
|
}
|
||
|
|
localStorage.setItem('szkp_blacklist_refresh_hint', '1')
|
||
|
|
Message.success('已加入灰名单')
|
||
|
|
blacklistVisible.value = false
|
||
|
|
await loadRows()
|
||
|
|
return true
|
||
|
|
} catch (error: any) {
|
||
|
|
Message.error(error?.response?.data?.message ?? '操作失败')
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
loadRows()
|
||
|
|
})
|
||
|
|
|
||
|
|
watch(
|
||
|
|
exportFields,
|
||
|
|
(val) => {
|
||
|
|
localStorage.setItem(EXPORT_FIELDS_KEY, JSON.stringify(val))
|
||
|
|
},
|
||
|
|
{ deep: true },
|
||
|
|
)
|
||
|
|
|
||
|
|
function onPageChange(p: number) {
|
||
|
|
pagination.current = p
|
||
|
|
loadRows()
|
||
|
|
}
|
||
|
|
|
||
|
|
function onSelectionChange(keys: (number | string)[]) {
|
||
|
|
selectedKeys.value = keys
|
||
|
|
}
|
||
|
|
</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-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 @click="loadRows">刷新</a-button>
|
||
|
|
<a-button status="warning" @click="openBatchQuickBlacklist">批量列入灰名单</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"
|
||
|
|
:row-selection="{ type: 'checkbox', selectedRowKeys: selectedKeys, showCheckedAll: true }"
|
||
|
|
:pagination="{
|
||
|
|
current: pagination.current,
|
||
|
|
pageSize: pagination.pageSize,
|
||
|
|
total: pagination.total,
|
||
|
|
showTotal: true,
|
||
|
|
}"
|
||
|
|
@selection-change="onSelectionChange"
|
||
|
|
@page-change="onPageChange"
|
||
|
|
>
|
||
|
|
<template #columns>
|
||
|
|
<a-table-column title="" :width="50" :ellipsis="true" :tooltip="true">
|
||
|
|
<template #cell="{ rowIndex }">{{
|
||
|
|
listTableRowIndex(rowIndex, pagination.current, pagination.pageSize)
|
||
|
|
}}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column title="活动" :width="280" :min-width="220">
|
||
|
|
<template #cell="{ record }">{{ record.activity?.title || '-' }}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column title="场馆" :width="220" :min-width="180">
|
||
|
|
<template #cell="{ record }">{{ record.venue?.name || '-' }}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column title="报名人" data-index="visitor_name" :width="140" :min-width="120" />
|
||
|
|
<a-table-column title="手机号" data-index="visitor_phone" :width="150" :min-width="130" />
|
||
|
|
<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="110">
|
||
|
|
<template #cell="{ record }">{{ record.ticket_count ?? 1 }}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column title="场次名称" :width="160" :min-width="120" :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="120">
|
||
|
|
<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">
|
||
|
|
<template #cell="{ record }">{{ formatDateTimeZh(record.created_at) }}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column title="核销时间" :width="190" :min-width="175">
|
||
|
|
<template #cell="{ record }">{{ formatDateTimeZh(record.verified_at) }}</template>
|
||
|
|
</a-table-column>
|
||
|
|
<a-table-column
|
||
|
|
title="二维码 token"
|
||
|
|
data-index="qr_token"
|
||
|
|
:width="220"
|
||
|
|
:min-width="180"
|
||
|
|
:ellipsis="true"
|
||
|
|
:tooltip="true"
|
||
|
|
fixed="right"
|
||
|
|
align="left"
|
||
|
|
/>
|
||
|
|
<a-table-column title="操作" :width="140" fixed="right" align="center">
|
||
|
|
<template #cell="{ record }">
|
||
|
|
<a-button v-if="!record.is_blacklisted" type="text" status="warning" @click="openQuickBlacklist(record)">
|
||
|
|
一键列入灰名单
|
||
|
|
</a-button>
|
||
|
|
</template>
|
||
|
|
</a-table-column>
|
||
|
|
</template>
|
||
|
|
</a-table>
|
||
|
|
|
||
|
|
<a-modal v-model:visible="blacklistVisible" title="灰名单原因" :on-before-ok="submitQuickBlacklist">
|
||
|
|
<a-form layout="vertical" class="admin-modal-form">
|
||
|
|
<a-form-item label="原因" required class="admin-modal-form__full">
|
||
|
|
<a-textarea v-model="blacklistReason" placeholder="请输入原因" :max-length="255" show-word-limit />
|
||
|
|
</a-form-item>
|
||
|
|
</a-form>
|
||
|
|
</a-modal>
|
||
|
|
</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;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|