master
lion 1 month ago
parent f88c4cfe2f
commit ff0f239ac9

@ -16,10 +16,17 @@ const baseURL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? 'ht
/** 生产环境后台 SPA 挂在 /admin/,须带 BASE_URL否则会跳到 /h5/ 的 uni-app 与核销页冲突 */
export function verifyLoginAbsoluteUrl(): string {
return buildVerifyLoginUrlWithPortal()
return buildUnifiedActivityVerifyLoginUrl()
}
/** 构建核销登录页完整 URL优先附带短码 v=,其次兼容旧 portal= */
/** 活动核销:全平台统一入口链接(不在 URL 上区分活动),活动由 6 位数字口令区分 */
export function buildUnifiedActivityVerifyLoginUrl(): string {
const pathname = window.location.pathname || ''
const subPath = pathname.includes('/m/verify') ? 'm/verify/login' : 'h5/verify/login'
const base = import.meta.env.BASE_URL || '/'
const normalized = base.endsWith('/') ? base : `${base}/`
return `${window.location.origin}${normalized}${subPath}`
}
export function buildVerifyLoginUrlWithPortal(): string {
const code = localStorage.getItem(VERIFY_PORTAL_CODE_KEY)
const legacy = localStorage.getItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY)
@ -66,7 +73,7 @@ h5Http.interceptors.response.use(
localStorage.removeItem(H5_TOKEN_KEY)
const path = window.location.pathname || ''
if (path.includes('/h5/verify') || path.includes('/m/verify')) {
window.location.replace(buildVerifyLoginUrlWithPortal())
window.location.replace(buildUnifiedActivityVerifyLoginUrl())
}
}
return Promise.reject(error)

File diff suppressed because it is too large Load Diff

@ -8,7 +8,7 @@ import { reservationStatusLabel } from '../../utils/reservationStatus'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import * as XLSX from 'xlsx'
const REGISTRATIONS_LIST_SCROLL_X = 2100
const REGISTRATIONS_LIST_SCROLL_X = 1960
type ActivityDayRef = {
id: number
@ -32,7 +32,6 @@ type Registration = {
venue?: { id: number; name: string }
activity?: { id: number; title: string }
activity_day?: ActivityDayRef | null
is_blacklisted?: boolean
}
function formatActivitySessionTime(ad: ActivityDayRef | null | undefined): string {
@ -59,7 +58,7 @@ function formatActivitySessionTime(ad: ActivityDayRef | null | undefined): strin
}
const loading = ref(false)
const status = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('all')
const status = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('pending')
const keyword = ref('')
const dateRange = ref<string[]>([])
const exportScope = ref<'current' | 'all'>('current')
@ -81,24 +80,78 @@ const exportFields = ref<string[]>([
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)
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 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 })
const { data } = await http.get('/activity-registrations', { params: buildListParams() })
rows.value = data.data
pagination.total = data.total
} catch (error: any) {
@ -123,10 +176,7 @@ async function exportCsv() {
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,
...buildListParams(),
page: 1,
page_size: 5000,
},
@ -166,53 +216,14 @@ async function exportCsv() {
}
}
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
}
}
watch(filterVenueId, async () => {
filterActivityId.value = undefined
await loadActivityOptions()
pagination.current = 1
void loadRows()
})
onMounted(() => {
onMounted(async () => {
const cached = localStorage.getItem(EXPORT_FIELDS_KEY)
if (cached) {
try {
@ -224,7 +235,9 @@ onMounted(() => {
// ignore
}
}
loadRows()
await loadMe()
await loadVenuesAndActivities()
await loadRows()
})
watch(
@ -239,10 +252,6 @@ function onPageChange(p: number) {
pagination.current = p
loadRows()
}
function onSelectionChange(keys: (number | string)[]) {
selectedKeys.value = keys
}
</script>
<template>
@ -256,11 +265,29 @@ function onSelectionChange(keys: (number | string)[]) {
<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-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">
@ -298,14 +325,12 @@ function onSelectionChange(keys: (number | string)[]) {
: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>
@ -367,23 +392,8 @@ function onSelectionChange(keys: (number | string)[]) {
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>

@ -1,172 +1,76 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import {
h5Http,
H5_TOKEN_KEY,
VERIFY_AUTH_MODE_KEY,
VERIFY_PORTAL_CODE_KEY,
VERIFY_PORTAL_LEGACY_TOKEN_KEY,
} from '../../api/h5Http'
import { h5Http, H5_TOKEN_KEY, VERIFY_AUTH_MODE_KEY, VERIFY_PORTAL_CODE_KEY, VERIFY_PORTAL_LEGACY_TOKEN_KEY } from '../../api/h5Http'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
/** 短码 v= */
const portalCode = ref('')
/** 旧链接 portal= 长 UUID */
const legacyPortalToken = ref('')
/** 入口解析的活动/抢票标题(仅展示) */
const portalEventTitle = ref('')
const portalPreviewLoading = ref(false)
const form = reactive({
username: '',
password: '',
})
const loadingPin = ref(false)
function readQuery(name: string): string {
const q = route.query[name]
const raw = typeof q === 'string' ? q : Array.isArray(q) ? String(q[0] ?? '') : ''
return raw.trim()
}
async function loadPortalPreview() {
const v = portalCode.value
const p = legacyPortalToken.value
if (v.length >= 6) {
portalPreviewLoading.value = true
try {
const { data } = await h5Http.get<{ event_title?: string }>('/verify-portal/preview', {
params: { portal_code: v },
})
portalEventTitle.value = String(data?.event_title || '').trim() || '—'
} catch {
portalEventTitle.value = ''
} finally {
portalPreviewLoading.value = false
}
return
}
if (p.length >= 32) {
portalPreviewLoading.value = true
try {
const { data } = await h5Http.get<{ event_title?: string }>('/verify-portal/preview', {
params: { portal_token: p },
})
portalEventTitle.value = String(data?.event_title || '').trim() || '—'
} catch {
portalEventTitle.value = ''
} finally {
portalPreviewLoading.value = false
}
return
}
portalEventTitle.value = ''
}
onMounted(() => {
const v = readQuery('v').toLowerCase()
const p = readQuery('portal')
if (v.length >= 6) {
portalCode.value = v
legacyPortalToken.value = ''
localStorage.setItem(VERIFY_PORTAL_CODE_KEY, v)
localStorage.removeItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY)
} else if (p.length >= 32) {
legacyPortalToken.value = p
portalCode.value = ''
localStorage.setItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY, p)
localStorage.removeItem(VERIFY_PORTAL_CODE_KEY)
} else {
portalCode.value = localStorage.getItem(VERIFY_PORTAL_CODE_KEY) || ''
legacyPortalToken.value = localStorage.getItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY) || ''
}
void loadPortalPreview()
})
/** 活动口令与抢票场馆口令均为 6 位数字(不同场景由后台发放的口令区分) */
const sixPin = ref('')
function verifyHomePath() {
return route.path.startsWith('/m/') ? '/m/verify' : '/h5/verify/scan'
}
async function login() {
loading.value = true
try {
if (portalCode.value.length >= 6) {
const { data } = await h5Http.post('/verify-portal/login', {
portal_code: portalCode.value,
username: form.username.trim(),
password: form.password,
})
localStorage.setItem(H5_TOKEN_KEY, data.token)
localStorage.setItem(`${H5_TOKEN_KEY}_saved_at`, String(Date.now()))
localStorage.setItem(VERIFY_AUTH_MODE_KEY, 'portal')
Message.success('登录成功')
router.replace(verifyHomePath())
return
}
if (legacyPortalToken.value.length >= 32) {
const { data } = await h5Http.post('/verify-portal/login', {
portal_token: legacyPortalToken.value,
username: form.username.trim(),
password: form.password,
})
localStorage.setItem(H5_TOKEN_KEY, data.token)
localStorage.setItem(`${H5_TOKEN_KEY}_saved_at`, String(Date.now()))
localStorage.setItem(VERIFY_AUTH_MODE_KEY, 'portal')
Message.success('登录成功')
router.replace(verifyHomePath())
return
}
function sanitizePinDigits() {
sixPin.value = sixPin.value.replace(/\D/g, '').slice(0, 6)
}
const { data } = await h5Http.post('/auth/login', {
...form,
client: 'h5_verify',
async function loginSixPin() {
sanitizePinDigits()
if (!/^\d{6}$/.test(sixPin.value.trim())) {
Message.warning('请输入 6 位数字核销口令')
return
}
loadingPin.value = true
try {
const { data } = await h5Http.post('/verify-portal/login', {
password: sixPin.value.trim(),
})
localStorage.setItem(H5_TOKEN_KEY, data.token)
localStorage.setItem(`${H5_TOKEN_KEY}_saved_at`, String(Date.now()))
localStorage.setItem(VERIFY_AUTH_MODE_KEY, 'admin')
localStorage.removeItem(VERIFY_PORTAL_CODE_KEY)
localStorage.removeItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY)
localStorage.setItem(VERIFY_AUTH_MODE_KEY, 'portal')
Message.success('登录成功')
router.replace(verifyHomePath())
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '登录失败')
} finally {
loading.value = false
loadingPin.value = false
}
}
onMounted(() => {
localStorage.removeItem(VERIFY_PORTAL_CODE_KEY)
localStorage.removeItem(VERIFY_PORTAL_LEGACY_TOKEN_KEY)
})
</script>
<template>
<div class="m-verify-page">
<div class="m-verify-hero">
<div class="m-verify-title">核销入口</div>
<div class="m-verify-sub">
<template v-if="portalCode.length >= 6 || legacyPortalToken.length >= 32">
{{ portalPreviewLoading ? '活动信息加载中…' : portalEventTitle || '—' }}
</template>
<template v-else></template>
</div>
</div>
<div class="m-verify-card">
<a-form :model="form" layout="vertical" @submit-success="login">
<a-form-item label="用户名">
<a-input v-model="form.username" placeholder="请输入账号" size="large" allow-clear />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="form.password" placeholder="请输入密码" size="large" allow-clear />
</a-form-item>
<a-button type="primary" long size="large" :loading="loading" @click="login"></a-button>
</a-form>
<p v-if="portalCode.length >= 6 || legacyPortalToken.length >= 32" class="m-verify-tip">
活动专用核销登录活动结束后账号失效
</p>
<p v-else class="m-verify-tip">
<strong>超级管理员</strong>可使用后台账号登录本页场馆工作人员请打开管理员提供的带 <strong>?v=短码</strong> 的专用链接
</p>
<p class="m-verify-tip">登录状态将保持较长时间若已失效会自动回本页</p>
<div class="m-verify-section-head">6 位数字核销口令</div>
<a-input
v-model="sixPin"
maxlength="6"
size="large"
placeholder="6 位数字口令"
class="m-verify-pin"
style="margin-bottom: 12px"
inputmode="numeric"
autocomplete="one-time-code"
@input="sanitizePinDigits"
@keyup.enter="loginSixPin"
/>
<a-button type="primary" long size="large" :loading="loadingPin" @click="loginSixPin"> </a-button>
<p class="m-verify-tip">登录状态将保持较长时间失效会自动退回本页</p>
</div>
</div>
</template>
@ -189,11 +93,6 @@ async function login() {
color: #1d2129;
letter-spacing: 0.02em;
}
.m-verify-sub {
margin-top: 8px;
font-size: 14px;
color: #86909c;
}
.m-verify-card {
max-width: 420px;
margin: 0 auto;
@ -208,4 +107,16 @@ async function login() {
color: #86909c;
line-height: 1.5;
}
.m-verify-section-head {
font-size: 15px;
font-weight: 600;
color: #4e5969;
margin-bottom: 14px;
}
.m-verify-pin :deep(input) {
font-size: 20px;
letter-spacing: 0.35em;
font-variant-numeric: tabular-nums;
text-indent: 0.05em;
}
</style>

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { http } from '../../api/http'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
@ -45,6 +45,17 @@ const form = reactive({
}
const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' }
const listPagination = reactive({ current: 1, pageSize: 10 })
const filterVenueId = ref<number | undefined>(undefined)
const filteredUsers = computed(() => {
const vid = filterVenueId.value
if (vid == null || vid <= 0) return users.value
return users.value.filter((u) => u.venues.some((v) => v.id === vid))
})
watch(filterVenueId, () => {
listPagination.current = 1
})
const isEdit = computed(() => editingId.value !== null)
@ -74,6 +85,7 @@ async function loadData() {
name: r.name,
}))
listPagination.current = 1
filterVenueId.value = undefined
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '加载失败')
} finally {
@ -161,16 +173,27 @@ onMounted(loadData)
场馆管理员可绑定多个场馆后续将用于限制仅查看/编辑自己的场馆仅核销自己场馆预约二维码
</a-alert>
<a-space wrap :size="12" style="margin-bottom: 12px">
<a-select
v-model="filterVenueId"
allow-clear
placeholder="按绑定场馆筛选"
style="width: 240px"
>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
</a-space>
<a-table
class="list-data-table"
:scroll="{ x: ADMINS_LIST_SCROLL_X }"
:data="users"
:data="filteredUsers"
:loading="loading"
row-key="id"
:pagination="{
current: listPagination.current,
pageSize: listPagination.pageSize,
total: users.length,
total: filteredUsers.length,
showTotal: true,
}"
@page-change="(p: number) => (listPagination.current = p)"

@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconEye, IconEyeInvisible } from '@arco-design/web-vue/es/icon'
import RichEditorField from '../../components/RichEditorField.vue'
import { http } from '../../api/http'
import { buildVerifyPortalPublicUrl } from '../../api/h5Http'
import { buildUnifiedActivityVerifyLoginUrl } from '../../api/h5Http'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { resolvePublicMediaUrl } from '../../utils/mediaUrl'
@ -104,7 +103,6 @@ const venueDetailRow = computed(() => {
return form.venues.find((r) => r._key === k) ?? null
})
const releaseVisible = ref(false)
const releaseLoading = ref(false)
const releaseSaving = ref(false)
const releaseEventId = ref<number | null>(null)
@ -126,39 +124,21 @@ const releasePayload = ref<{
const releaseEdits = ref<Record<number, Record<string, number>>>({})
const tagInput = ref('')
type TgVerifyCredRow = {
id: number
/** 详情弹窗(活动信息 + 放票 + 核销) */
const tgDetailHubVisible = ref(false)
const tgHubActiveKey = ref<'1' | '2' | '3'>('1')
const tgHubTitle = ref('')
const tgHubSummary = ref<Record<string, unknown> | null>(null)
const tgHubSummaryLoading = ref(false)
type TgVenueVerifyPinRow = {
venue_id: number
venue_name?: string
username: string
password_plain?: string | null
note?: string | null
created_at?: string
}
const tgVerifyVisible = ref(false)
const tgVerifyLoading = ref(false)
const tgVerifyEventId = ref<number | null>(null)
const tgVerifyVenues = ref<{ id: number; name: string }[]>([])
const tgVerifyUrl = ref('')
const tgVerifyCreds = ref<TgVerifyCredRow[]>([])
const tgVerifyCredForm = reactive({ venue_id: undefined as number | undefined, username: '', password: '', note: '' })
const tgVerifyCredSaving = ref(false)
/** 核销登录密码(与后端 Password 规则一致) */
const TG_VERIFY_PASSWORD_RULE_HINT = '最少 8 位,须含大写、小写字母与特殊字符'
function validateTgVerifyPasswordStrength(pw: string): string | null {
if (pw.length > 72) return '密码最长 72 位'
if (pw.length < 8) {
return '密码最少8位且含大写、小写字母与特殊字符'
}
if (!/[a-z]/.test(pw)) return '密码须包含小写字母'
if (!/[A-Z]/.test(pw)) return '密码须包含大写字母'
if (!/[\p{S}\p{P}\p{Z}]/u.test(pw)) return '密码须包含特殊字符(标点、符号等)'
return null
verify_portal_pin?: string
}
/** 核销账号表格:是否明文显示密码(按行 id */
const tgPasswordReveal = ref<Record<number, boolean>>({})
const tgVerifyUnifiedUrl = ref('')
const tgVerifyVenuePins = ref<TgVenueVerifyPinRow[]>([])
const tgVerifyNotice = ref('')
const mapVisible = ref(false)
const mapLoading = ref(false)
@ -708,6 +688,43 @@ function scheduleLabel(s?: string) {
return s ?? '-'
}
function scheduleTagColor(s?: string) {
if (s === 'not_started') return 'arcoblue'
if (s === 'ongoing') return 'green'
if (s === 'ended') return 'gray'
return undefined
}
function auditStatusLabel(s?: unknown) {
const v = String(s ?? '').trim()
if (v === 'pending') return '待审核'
if (v === 'approved') return '已通过'
if (v === 'rejected') return '已驳回'
return v || '—'
}
function auditTagColor(s?: unknown) {
const v = String(s ?? '').trim()
if (v === 'pending') return 'orangered'
if (v === 'approved') return 'green'
if (v === 'rejected') return 'red'
return undefined
}
function tgHubDateRange(start: unknown, end: unknown) {
const a = start != null ? String(start).slice(0, 10) : ''
const b = end != null ? String(end).slice(0, 10) : ''
const parts = [a, b].filter(Boolean)
return parts.length ? parts.join(' ') : '—'
}
function tgHubAgeLimitLabel(s: Record<string, unknown> | null): string {
if (!s) return '—'
const a = s.age_limit_start ? String(s.age_limit_start).slice(0, 10) : ''
const b = s.age_limit_end ? String(s.age_limit_end).slice(0, 10) : ''
return a || b ? `${a || '—'} ${b || '—'}` : '不限制'
}
function abbr(s: string, n: number) {
if (!s) return '—'
return s.length <= n ? s : `${s.slice(0, n)}`
@ -938,18 +955,25 @@ async function saveForm(): Promise<boolean> {
saving.value = true
try {
if (editId.value) {
await http.put(`/ticket-grab-events/${editId.value}`, body)
const savedId = editId.value
await http.put(`/ticket-grab-events/${savedId}`, body)
Message.success('已保存')
visible.value = false
await loadRows()
await nextTick()
const row =
rows.value.find((r) => r.id === savedId) ??
({ id: savedId, title: form.title.trim(), is_active: form.is_active } as TgRow)
await openTicketGrabDetailHub(row, '2')
return true
}
const { data: created } = await http.post<TgRow>('/ticket-grab-events', body)
Message.success('已创建')
visible.value = false
await loadRows()
await nextTick()
if (created?.id != null) {
await openRelease(created)
await openTicketGrabDetailHub(created as TgRow, '2')
}
return true
} catch (e: any) {
@ -985,14 +1009,12 @@ async function removeTicketGrab(row: TgRow) {
}
}
async function openRelease(row: TgRow) {
releaseEventId.value = row.id
releaseVisible.value = true
async function loadHubReleaseConfig(eventId: number): Promise<boolean> {
releaseLoading.value = true
releasePayload.value = null
releaseEdits.value = {}
try {
const { data } = await http.get(`/ticket-grab-events/${row.id}/release-config`)
const { data } = await http.get(`/ticket-grab-events/${eventId}/release-config`)
releasePayload.value = data
for (const b of data.venues ?? []) {
const vid = b.venue_id
@ -1001,97 +1023,116 @@ async function openRelease(row: TgRow) {
releaseEdits.value[vid][d.release_date] = d.day_quota
}
}
return true
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '加载放票配置失败')
releaseVisible.value = false
return false
} finally {
releaseLoading.value = false
}
}
async function loadTgVerifyData(eventId: number) {
const { data } = await http.get<{ verify_portal_code: string; credentials: TgVerifyCredRow[] }>(
`/ticket-grab-events/${eventId}/verify-portal`,
)
tgVerifyUrl.value = buildVerifyPortalPublicUrl(data.verify_portal_code)
tgVerifyCreds.value = data.credentials || []
function buildRowFallbackSummary(row: TgRow): Record<string, unknown> {
return {
title: row.title,
tags: row.tags ?? [],
start_at: row.start_at,
end_at: row.end_at,
booking_start_at: row.booking_start_at,
booking_end_at: row.booking_end_at,
is_active: row.is_active,
total_quota: row.total_quota,
registered_count: row.registered_count,
venues: row.venues ?? [],
}
}
async function openTicketGrabVerify(row: TgRow) {
tgVerifyEventId.value = row.id
tgVerifyVenues.value = row.venues?.length ? row.venues : []
tgPasswordReveal.value = {}
tgVerifyVisible.value = true
tgVerifyLoading.value = true
tgVerifyCredForm.venue_id = tgVerifyVenues.value[0]?.id
try {
await loadTgVerifyData(row.id)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '加载核销配置失败')
tgVerifyVisible.value = false
} finally {
tgVerifyLoading.value = false
async function openTicketGrabDetailHub(row: TgRow, initialTab: '1' | '2' | '3' = '1') {
releaseEventId.value = row.id
tgHubTitle.value = (row.title && String(row.title).trim()) || `抢票 #${row.id}`
tgHubActiveKey.value = initialTab
tgDetailHubVisible.value = true
tgHubSummary.value = null
tgHubSummaryLoading.value = true
releasePayload.value = null
releaseEdits.value = {}
tgVerifyNotice.value = ''
tgVerifyUnifiedUrl.value = buildUnifiedActivityVerifyLoginUrl()
tgVerifyVenuePins.value = []
const detailP = http
.get(`/ticket-grab-events/${row.id}`)
.then((r) => r.data as Record<string, unknown>)
.catch(() => null)
const verifyP = http
.get(`/ticket-grab-events/${row.id}/verify-portal`)
.then((r) => r.data as { unified_verify_notice?: string; venues?: TgVenueVerifyPinRow[] })
.catch(() => null)
const [detail, _relOk, verifyData] = await Promise.all([
detailP,
loadHubReleaseConfig(row.id),
verifyP,
])
if (detail) {
const d = { ...detail } as Record<string, unknown>
if (d.cover_image) {
d.cover_image = resolvePublicMediaUrl(String(d.cover_image))
}
if (Array.isArray(d.gallery_media)) {
d.gallery_media = (d.gallery_media as GalleryItem[]).map((x) => ({
...x,
url: resolvePublicMediaUrl(String((x as { url?: string }).url || '')),
}))
}
tgHubSummary.value = d
} else {
tgHubSummary.value = buildRowFallbackSummary(row)
}
if (verifyData) {
tgVerifyNotice.value = String(verifyData.unified_verify_notice || '').trim()
tgVerifyVenuePins.value = Array.isArray(verifyData.venues) ? verifyData.venues : []
}
tgVerifyUnifiedUrl.value = buildUnifiedActivityVerifyLoginUrl()
tgHubSummaryLoading.value = false
}
function copyTgVerifyUrl() {
void navigator.clipboard.writeText(tgVerifyUrl.value)
Message.success('核销链接已复制')
function closeTicketGrabDetailHub() {
tgDetailHubVisible.value = false
releaseEventId.value = null
releasePayload.value = null
releaseEdits.value = {}
tgHubSummary.value = null
}
function toggleTgPasswordReveal(id: number) {
const cur = { ...tgPasswordReveal.value }
cur[id] = !cur[id]
tgPasswordReveal.value = cur
function tgBookingAudienceLabel(v: unknown): string {
const s = String(v ?? 'all')
if (s === 'school_age') return '学龄内学生'
return '全部人员'
}
async function addTgVerifyCredential() {
if (!tgVerifyEventId.value || !tgVerifyCredForm.venue_id) {
Message.warning('请选择场馆并填写用户名与密码')
return
}
if (!tgVerifyCredForm.username.trim() || !tgVerifyCredForm.password) {
Message.warning('请填写用户名与密码')
return
}
const pwdErr = validateTgVerifyPasswordStrength(tgVerifyCredForm.password)
if (pwdErr) {
Message.warning(pwdErr)
return
}
tgVerifyCredSaving.value = true
try {
await http.post(`/ticket-grab-events/${tgVerifyEventId.value}/verify-credentials`, {
venue_id: tgVerifyCredForm.venue_id,
username: tgVerifyCredForm.username.trim(),
password: tgVerifyCredForm.password,
note: tgVerifyCredForm.note.trim() || undefined,
})
Message.success('已添加')
tgVerifyCredForm.username = ''
tgVerifyCredForm.password = ''
tgVerifyCredForm.note = ''
await loadTgVerifyData(tgVerifyEventId.value)
} catch (e: any) {
const errs = e?.response?.data?.errors as Record<string, string[]> | undefined
const pwErr = errs?.password
const msg =
Array.isArray(pwErr) && pwErr.length > 0 ? String(pwErr[0]) : e?.response?.data?.message ?? '添加失败'
Message.error(msg)
} finally {
tgVerifyCredSaving.value = false
}
const tgHubPivots = computed(() => {
const s = tgHubSummary.value
if (!s) return []
const raw = s.event_venue_pivots ?? s.eventVenuePivots
return Array.isArray(raw) ? raw : []
})
function copyTgVerifyUrl() {
void navigator.clipboard.writeText(tgVerifyUnifiedUrl.value)
Message.success('核销链接已复制')
}
async function removeTgVerifyCredential(row: TgVerifyCredRow) {
if (!tgVerifyEventId.value) return
try {
await http.delete(`/ticket-grab-events/${tgVerifyEventId.value}/verify-credentials/${row.id}`)
Message.success('已删除')
tgVerifyCreds.value = tgVerifyCreds.value.filter((c) => c.id !== row.id)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '删除失败')
}
function copyVenuePin(pin: string) {
void navigator.clipboard.writeText(String(pin || '').trim())
Message.success('口令已复制')
}
async function saveRelease() {
@ -1112,7 +1153,9 @@ async function saveRelease() {
venue_day_quotas: blocks,
})
Message.success('放票日配置已保存')
releaseVisible.value = false
if (releaseEventId.value != null) {
await loadHubReleaseConfig(releaseEventId.value)
}
} catch (e: any) {
const msg = e?.response?.data?.message
if (e?.response?.data?.errors) {
@ -1218,8 +1261,7 @@ onMounted(async () => {
<template #cell="{ record }">
<a-space :size="2" class="tg-list-actions" align="start">
<a-button type="text" size="small" @click="openEdit(record)"></a-button>
<a-button type="text" size="small" @click="openRelease(record)"></a-button>
<a-button type="text" size="small" @click="openTicketGrabVerify(record)"></a-button>
<a-button type="text" size="small" @click="openTicketGrabDetailHub(record as TgRow, '1')">查看</a-button>
<a-button type="text" size="small" status="warning" @click="toggleRow(record)">{{
record.is_active ? '下架' : '上架'
}}</a-button>
@ -1237,77 +1279,285 @@ onMounted(async () => {
</a-table>
</a-space>
<a-modal v-model:visible="tgVerifyVisible" title="核销管理" width="840px" :footer="false">
<a-spin :loading="tgVerifyLoading" style="width: 100%">
<a-typography-paragraph type="secondary" style="margin-bottom: 12px">
不同参与场馆可分别配置多组账号场馆后台账号<strong>不可</strong>登录核销页
</a-typography-paragraph>
<a-form layout="vertical">
<a-form-item label="独立核销链接">
<a-space>
<a-input :model-value="tgVerifyUrl" readonly style="width: 500px" />
<a-button type="primary" @click="copyTgVerifyUrl"></a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider orientation="left">按场馆添加核销账号</a-divider>
<div class="tg-verify-cred-add">
<a-space style="margin-bottom: 8px; flex-wrap: wrap; align-items: flex-start">
<a-select
v-model="tgVerifyCredForm.venue_id"
placeholder="场馆"
allow-clear
style="width: 200px"
:options="tgVerifyVenues.map((v) => ({ label: v.name, value: v.id }))"
/>
<a-input v-model="tgVerifyCredForm.username" placeholder="用户名" style="width: 140px" allow-clear />
<a-input-password v-model="tgVerifyCredForm.password" placeholder="密码" style="width: 180px" allow-clear />
<a-input v-model="tgVerifyCredForm.note" placeholder="备注" style="width: 140px" allow-clear />
<a-button type="primary" :loading="tgVerifyCredSaving" @click="addTgVerifyCredential"></a-button>
</a-space>
<div class="tg-verify-cred-add__hint">密码要求{{ TG_VERIFY_PASSWORD_RULE_HINT }}</div>
</div>
<a-table :data="tgVerifyCreds" :pagination="false" size="small" row-key="id">
<template #columns>
<a-table-column title="场馆" data-index="venue_name" />
<a-table-column title="用户名" data-index="username" />
<a-table-column title="密码" :width="168">
<template #cell="{ record }">
<a-space :size="4">
<span>{{
tgPasswordReveal[(record as TgVerifyCredRow).id]
? (record as TgVerifyCredRow).password_plain || '—'
: '*****'
}}</span>
<a-button
type="text"
size="mini"
class="tg-pw-eye"
@click="toggleTgPasswordReveal((record as TgVerifyCredRow).id)"
<a-modal
v-model:visible="tgDetailHubVisible"
:title="tgHubTitle"
width="960px"
:footer="false"
unmount-on-close
body-class="tg-detail-hub-modal-body"
@cancel="closeTicketGrabDetailHub"
>
<a-tabs v-model:active-key="tgHubActiveKey" type="rounded">
<a-tab-pane key="1" title="活动信息">
<div class="tg-detail-hub-pane">
<a-spin style="width: 100%" :loading="tgHubSummaryLoading">
<template v-if="tgHubSummary">
<div class="tg-hub-activity">
<div class="tg-hub-activity__head">
<div class="tg-hub-activity__title-row">
<a-typography-title :heading="5" class="tg-hub-activity__name">
{{ String(tgHubSummary.title ?? '—') }}
</a-typography-title>
<a-space wrap :size="8">
<a-tag v-if="releaseEventId != null" size="small">#{{ releaseEventId }}</a-tag>
<a-tag :color="tgHubSummary.is_active ? 'green' : 'gray'" size="small">
{{ tgHubSummary.is_active ? '已上架' : '已下架' }}
</a-tag>
<a-tag
v-if="tgHubSummary.schedule_status"
:color="scheduleTagColor(String(tgHubSummary.schedule_status))"
size="small"
>
{{ scheduleLabel(String(tgHubSummary.schedule_status)) }}
</a-tag>
<a-tag
v-if="tgHubSummary.audit_status"
:color="auditTagColor(tgHubSummary.audit_status)"
size="small"
>
{{ auditStatusLabel(tgHubSummary.audit_status) }}
</a-tag>
</a-space>
</div>
<div
v-if="Array.isArray(tgHubSummary.tags) && (tgHubSummary.tags as string[]).length"
class="tg-hub-activity__tags"
>
<a-tag
v-for="(tag, i) in tgHubSummary.tags as string[]"
:key="`${tag}-${i}`"
color="arcoblue"
size="small"
>
{{ tag }}
</a-tag>
</div>
</div>
<a-divider class="tg-hub-divider" />
<section class="tg-hub-section">
<h6 class="tg-hub-section__title">时间与规则</h6>
<a-descriptions :column="2" size="small" bordered class="tg-hub-desc">
<a-descriptions-item label="活动日期">
{{ tgHubDateRange(tgHubSummary.start_at, tgHubSummary.end_at) }}
</a-descriptions-item>
<a-descriptions-item label="预约日期">
{{ tgHubDateRange(tgHubSummary.booking_start_at, tgHubSummary.booking_end_at) }}
</a-descriptions-item>
<a-descriptions-item label="每日放票时段" :span="2">
{{ String(tgHubSummary.daily_release_start_time ?? '—') }}
{{ String(tgHubSummary.daily_release_end_time ?? '—') }}
</a-descriptions-item>
<a-descriptions-item label="预约人群">
{{ tgBookingAudienceLabel(tgHubSummary.booking_audience) }}
</a-descriptions-item>
<a-descriptions-item label="年龄限制">
{{ tgHubAgeLimitLabel(tgHubSummary as Record<string, unknown>) }}
</a-descriptions-item>
<a-descriptions-item label="放票总数">{{ Number(tgHubSummary.total_quota ?? 0) }}</a-descriptions-item>
<a-descriptions-item label="已预约">{{ Number(tgHubSummary.registered_count ?? 0) }}</a-descriptions-item>
</a-descriptions>
</section>
<section
v-if="
tgHubSummary.cover_image ||
(Array.isArray(tgHubSummary.gallery_media) && (tgHubSummary.gallery_media as GalleryItem[]).length)
"
class="tg-hub-section"
>
<IconEye v-if="!tgPasswordReveal[(record as TgVerifyCredRow).id]" />
<IconEyeInvisible v-else />
</a-button>
</a-space>
<h6 class="tg-hub-section__title">封面与轮播</h6>
<div class="tg-hub-media">
<div v-if="tgHubSummary.cover_image" class="tg-hub-media__cover">
<div class="tg-hub-media__sub">封面</div>
<img
:src="String(tgHubSummary.cover_image)"
alt=""
class="tg-hub-media__cover-img"
@click="openMediaPreview('image', String(tgHubSummary.cover_image))"
/>
</div>
<div
v-if="Array.isArray(tgHubSummary.gallery_media) && (tgHubSummary.gallery_media as GalleryItem[]).length"
class="tg-hub-media__gallery"
>
<div class="tg-hub-media__sub">轮播</div>
<div class="tg-hub-gallery-scroll">
<div
v-for="(item, idx) in tgHubSummary.gallery_media as GalleryItem[]"
:key="idx + item.url"
class="tg-hub-gallery-thumb-wrap"
>
<img
v-if="item.type === 'image'"
:src="item.url"
class="tg-hub-gallery-thumb"
@click="openMediaPreview('image', item.url)"
/>
<video
v-else
:src="item.url"
controls
class="tg-hub-gallery-thumb tg-hub-gallery-thumb--video"
@click.stop="openMediaPreview('video', item.url)"
/>
</div>
</div>
</div>
</div>
</section>
<section v-if="tgHubPivots.length" class="tg-hub-section">
<h6 class="tg-hub-section__title">参与场馆与配额</h6>
<div class="tg-hub-table-wrap">
<a-table
:data="tgHubPivots"
size="small"
:pagination="false"
row-key="venue_id"
:bordered="{ cell: true }"
>
<template #columns>
<a-table-column title="场馆" data-index="venue_id">
<template #cell="{ record }">{{
venueName((record as Record<string, unknown>).venue_id as number)
}}</template>
</a-table-column>
<a-table-column title="放票数" :width="96">
<template #cell="{ record }">{{
((record as Record<string, unknown>).venue_total_quota as number) ?? '—'
}}</template>
</a-table-column>
<a-table-column title="开放时间" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{
abbr(String((record as Record<string, unknown>).opening_hours ?? ''), 36)
}}</template>
</a-table-column>
<a-table-column title="地址" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{
abbr(String((record as Record<string, unknown>).address ?? ''), 40)
}}</template>
</a-table-column>
</template>
</a-table>
</div>
</section>
<section v-if="String(tgHubSummary.reservation_notice || '').trim()" class="tg-hub-section">
<h6 class="tg-hub-section__title">预约须知</h6>
<div class="tg-hub-rich-box">
<div class="tg-hub-rich" v-html="String(tgHubSummary.reservation_notice)" />
</div>
</section>
<section v-if="String(tgHubSummary.detail_html || '').trim()" class="tg-hub-section">
<h6 class="tg-hub-section__title">活动详情</h6>
<div class="tg-hub-rich-box">
<div class="tg-hub-rich" v-html="String(tgHubSummary.detail_html)" />
</div>
</section>
</div>
</template>
</a-table-column>
<a-table-column title="备注" data-index="note" />
<a-table-column title="创建时间" data-index="created_at" />
<a-table-column title="操作" :width="90">
<template #cell="{ record }">
<a-popconfirm content="确认删除?" @ok="removeTgVerifyCredential(record as TgVerifyCredRow)">
<a-button type="text" size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-spin>
</div>
</a-tab-pane>
<a-tab-pane key="2" title="放票设置">
<div class="tg-detail-hub-pane">
<a-spin v-if="releaseLoading" style="width: 100%; padding: 24px" />
<div v-else-if="releasePayload">
<a-typography-paragraph v-if="releasePayload.event" type="secondary" style="margin-top: 0">
预约日 {{ releasePayload.event.booking_start_at }} {{ releasePayload.event.booking_end_at }}每日
{{ releasePayload.event.daily_release_start_time }} 起开放
{{ releasePayload.event.daily_release_end_time }}
</a-typography-paragraph>
<a-tabs v-if="releasePayload.venues?.length" default-active-key="0">
<a-tab-pane
v-for="(b, idx) in releasePayload.venues"
:key="String(idx)"
:title="`${venueName(b.venue_id)} · 总配额 ${b.venue_total_quota}`"
>
<a-table :data="b.release_days" :pagination="false" :scroll="{ y: 320 }">
<template #columns>
<a-table-column title="抢票日期" data-index="release_date" :width="120" />
<a-table-column title="昨日余票" :width="88">
<template #cell="{ record }">{{ record.carry_in }}</template>
</a-table-column>
<a-table-column title="基础放票" :width="120">
<template #cell="{ record }">
<a-input-number
v-model="releaseEdits[b.venue_id][record.release_date]"
:min="0"
mode="button"
/>
</template>
</a-table-column>
<a-table-column title="可预约总量" :width="120">
<template #cell="{ record }">{{ record.total_day_pool }}</template>
</a-table-column>
<a-table-column title="已预约" :width="72">
<template #cell="{ record }">{{ record.booked_count }}</template>
</a-table-column>
<a-table-column title="余量" :width="88">
<template #cell="{ record }">{{ record.current_remaining }}</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<a-alert v-if="!releasePayload.venues?.length" type="warning"></a-alert>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="3" title="核销说明">
<div class="tg-detail-hub-pane">
<a-typography-paragraph v-if="tgVerifyNotice" type="secondary" style="margin-bottom: 12px">
{{ tgVerifyNotice }}
</a-typography-paragraph>
<a-form layout="vertical">
<a-form-item label="核销登录页(与活动核销相同)">
<a-space>
<a-input :model-value="tgVerifyUnifiedUrl" readonly style="width: 460px" />
<a-button type="primary" @click="copyTgVerifyUrl"></a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider orientation="left">本场参与场馆 · 6 位抢票核销口令</a-divider>
<a-table :data="tgVerifyVenuePins" :pagination="false" size="small" row-key="venue_id">
<template #columns>
<a-table-column title="场馆" data-index="venue_name" />
<a-table-column title="6 位口令" data-index="verify_portal_pin" />
<a-table-column title="操作" :width="100">
<template #cell="{ record }">
<a-button type="text" size="mini" @click="copyVenuePin(String((record as TgVenueVerifyPinRow).verify_portal_pin || ''))"
>复制口令</a-button
>
</template>
</a-table-column>
</template>
</a-table-column>
</template>
</a-table>
</a-spin>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px">
<a-button @click="closeTicketGrabDetailHub"></a-button>
<a-button
v-show="tgHubActiveKey === '2'"
type="primary"
:loading="releaseSaving"
:disabled="!releasePayload?.venues?.length"
@click="saveRelease"
>
保存放票配置
</a-button>
</div>
</a-modal>
<a-modal
v-model:visible="visible"
:title="editId ? '编辑抢票' : '新建抢票'"
ok-text="下一步:放票配置"
width="70%"
:body-style="modalBodyStyle"
:ok-loading="saving"
@ -1641,71 +1891,10 @@ onMounted(async () => {
地址{{ pickedPoint.address || '-' }}
</a-alert>
</a-modal>
<a-modal
v-model:visible="releaseVisible"
title="放票与每日配置"
:width="900"
:ok-loading="releaseSaving"
@ok="saveRelease"
@cancel="releaseVisible = false"
>
<a-spin v-if="releaseLoading" style="width: 100%; padding: 24px" />
<div v-else-if="releasePayload">
<a-typography-paragraph v-if="releasePayload.event" type="secondary" style="margin-top: 0">
预约日 {{ releasePayload.event.booking_start_at }} {{ releasePayload.event.booking_end_at }}每日
{{ releasePayload.event.daily_release_start_time }} 起开放
{{ releasePayload.event.daily_release_end_time }}
</a-typography-paragraph>
<a-tabs v-if="releasePayload.venues?.length" default-active-key="0">
<a-tab-pane
v-for="(b, idx) in releasePayload.venues"
:key="String(idx)"
:title="`${venueName(b.venue_id)} · 总配额 ${b.venue_total_quota}`"
>
<a-table :data="b.release_days" :pagination="false" :scroll="{ y: 320 }">
<template #columns>
<a-table-column title="抢票日期" data-index="release_date" :width="120" />
<a-table-column title="昨日余票" :width="88">
<template #cell="{ record }">{{ record.carry_in }}</template>
</a-table-column>
<a-table-column title="基础放票" :width="120">
<template #cell="{ record }">
<a-input-number
v-model="releaseEdits[b.venue_id][record.release_date]"
:min="0"
mode="button"
/>
</template>
</a-table-column>
<a-table-column title="可预约总量" :width="120">
<template #cell="{ record }">{{ record.total_day_pool }}</template>
</a-table-column>
<a-table-column title="已预约" :width="72">
<template #cell="{ record }">{{ record.booked_count }}</template>
</a-table-column>
<a-table-column title="余量" :width="88">
<template #cell="{ record }">{{ record.current_remaining }}</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<a-alert v-if="!releasePayload.venues?.length" type="warning"></a-alert>
</div>
</a-modal>
</a-card>
</template>
<style scoped>
.tg-verify-cred-add {
margin-bottom: 12px;
}
.tg-verify-cred-add__hint {
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
}
.tg-venue-block {
width: 100%;
}
@ -1813,4 +2002,154 @@ onMounted(async () => {
.activity-address-coord-row__map {
flex-shrink: 0;
}
.tg-detail-hub-pane {
max-height: min(70vh, 680px);
overflow-y: auto;
padding-right: 2px;
box-sizing: border-box;
}
.tg-hub-activity__title-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 16px;
justify-content: space-between;
}
.tg-hub-activity__name {
flex: 1 1 200px;
margin: 0 !important;
}
.tg-hub-activity__tags {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tg-hub-divider {
margin: 14px 0 !important;
}
.tg-hub-section {
margin-bottom: 20px;
}
.tg-hub-section:last-child {
margin-bottom: 2px;
}
.tg-hub-section__title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-2);
letter-spacing: 0.02em;
}
.tg-hub-desc {
width: 100%;
}
.tg-hub-desc :deep(.arco-descriptions-item-label) {
width: 112px;
white-space: nowrap;
}
.tg-hub-media {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-start;
}
.tg-hub-media__sub {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 8px;
}
.tg-hub-media__cover-img {
width: 200px;
max-width: 100%;
height: 112px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--color-border-2);
cursor: zoom-in;
display: block;
}
.tg-hub-media__gallery {
flex: 1 1 280px;
min-width: 0;
}
.tg-hub-gallery-scroll {
display: flex;
flex-wrap: nowrap;
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
max-width: 100%;
}
.tg-hub-gallery-thumb-wrap {
flex: 0 0 auto;
}
.tg-hub-gallery-thumb {
width: 128px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--color-border-2);
cursor: zoom-in;
display: block;
}
.tg-hub-gallery-thumb--video {
cursor: default;
}
.tg-hub-table-wrap {
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border-2);
}
.tg-hub-rich-box {
max-height: 280px;
overflow: auto;
padding: 12px 14px;
border-radius: 6px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-1);
}
.tg-hub-rich {
font-size: 14px;
line-height: 1.65;
color: var(--color-text-1);
}
.tg-hub-rich :deep(img) {
max-width: 100%;
height: auto;
}
.tg-hub-rich :deep(p) {
margin: 0.5em 0;
}
.tg-hub-rich :deep(p:first-child) {
margin-top: 0;
}
.tg-hub-rich :deep(p:last-child) {
margin-bottom: 0;
}
</style>

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
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'
@ -28,7 +28,7 @@ type Row = {
type TicketGrabEventOption = { id: number; title: string }
const loading = ref(false)
const status = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('all')
const status = ref<'all' | 'pending' | 'verified' | 'cancelled' | 'expired'>('pending')
const keyword = ref('')
const ticketGrabEventId = ref<number | undefined>(undefined)
const dateRange = ref<string[]>([])
@ -36,6 +36,35 @@ const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const rows = ref<Row[]>([])
const ticketGrabEvents = ref<TicketGrabEventOption[]>([])
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)
function isSuperAdmin() {
return currentUser.value?.full_admin_access === true
}
async function loadMe() {
try {
const { data } = await http.get('/me')
currentUser.value = data as CurrentUser
} catch {
currentUser.value = null
}
}
async function loadVenues() {
try {
const { data } = await http.get('/venues')
venuesList.value = Array.isArray(data) ? (data as VenueMini[]) : []
} catch {
venuesList.value = []
}
}
async function loadTicketGrabEvents() {
try {
const { data } = await http.get<{ data: { id: number; title: string }[] }>('/ticket-grab-events/options', {
@ -50,18 +79,20 @@ async function loadTicketGrabEvents() {
async function loadRows() {
loading.value = true
try {
const { data } = await http.get('/activity-registrations', {
params: {
reservation_kind: 'ticket_grab',
ticket_grab_event_id: ticketGrabEventId.value || undefined,
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 params: Record<string, unknown> = {
reservation_kind: 'ticket_grab',
ticket_grab_event_id: ticketGrabEventId.value || undefined,
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
}
const { data } = await http.get('/activity-registrations', { params })
rows.value = data.data
pagination.total = data.total
} catch (e: any) {
@ -82,7 +113,14 @@ function onPageSizeChange(s: number) {
void loadRows()
}
watch(filterVenueId, () => {
pagination.current = 1
void loadRows()
})
onMounted(async () => {
await loadMe()
await loadVenues()
await loadTicketGrabEvents()
await loadRows()
})
@ -92,6 +130,16 @@ onMounted(async () => {
<a-card title="抢票管理 / 抢票报名" :bordered="false">
<a-space direction="vertical" fill>
<a-space wrap :size="12">
<a-select
v-if="isSuperAdmin()"
v-model="filterVenueId"
allow-clear
allow-search
placeholder="搜索或选择场馆"
style="width: 260px"
>
<a-option v-for="v in venuesList" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
<a-select
v-model="ticketGrabEventId"
allow-clear

Loading…
Cancel
Save