master
lion 1 month ago
parent d0fbe268ef
commit 1afcd3181c

@ -3,6 +3,7 @@ import { computed, defineComponent, h, onMounted, provide, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import * as ArcoIcons from '@arco-design/web-vue/es/icon'
import { IconExport, IconUser } from '@arco-design/web-vue/es/icon'
import { ReloadAdminMenusKey } from '../constants/injectionKeys'
import { http, TOKEN_KEY } from '../api/http'
import { resetDynamicAdminRoutes } from '../router/dynamicAdminRoutes'
@ -37,6 +38,9 @@ const profileForm = ref({
const activePath = computed(() => route.path)
/** 右上角用户区展示名:优先姓名/用户名,否则「管理员」 */
const headerUserLabel = computed(() => currentUser.value?.name || currentUser.value?.username || '管理员')
const DynamicIcon = defineComponent({
name: 'DynamicIcon',
props: {
@ -193,9 +197,29 @@ onMounted(async () => {
<a-layout-header class="admin-header">
<div>{{ route.meta?.title ?? '后台管理' }}</div>
<div class="header-actions">
<a-tag color="arcoblue">{{ currentUser?.name || currentUser?.username || '未登录' }}</a-tag>
<a-button type="text" @click="openProfileModal"></a-button>
<a-button type="text" @click="logout">退</a-button>
<a-dropdown trigger="click" position="br" :hide-on-select="true">
<div class="header-user-trigger" tabindex="0" role="button" aria-haspopup="true">
<span class="header-user-trigger__avatar" aria-hidden="true">
<IconUser />
</span>
<span class="header-user-trigger__name">{{ headerUserLabel }}</span>
</div>
<template #content>
<a-doption value="profile" class="header-dropdown-option header-dropdown-option--profile" @click="openProfileModal">
<template #icon>
<IconUser />
</template>
个人信息
</a-doption>
<div class="header-user-menu-divider" role="separator" />
<a-doption value="logout" class="header-dropdown-option header-dropdown-option--logout" @click="logout">
<template #icon>
<IconExport />
</template>
退出登录
</a-doption>
</template>
</a-dropdown>
</div>
</a-layout-header>
@ -207,7 +231,7 @@ onMounted(async () => {
<a-modal
v-model:visible="profileVisible"
title="修改个人资料"
title="个人信息"
width="520px"
:confirm-loading="profileSaving"
:on-before-ok="submitProfile"
@ -278,6 +302,70 @@ onMounted(async () => {
align-items: center;
}
.header-user-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 10px 4px 6px;
border-radius: 6px;
outline: none;
user-select: none;
}
.header-user-trigger:hover {
background: var(--color-fill-2);
}
.header-user-trigger:focus-visible {
box-shadow: 0 0 0 2px var(--color-primary-light-3);
}
.header-user-trigger__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border-3);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-3);
flex-shrink: 0;
box-sizing: border-box;
}
.header-user-trigger__avatar :deep(svg) {
font-size: 18px;
}
.header-user-trigger__name {
font-size: 14px;
color: var(--color-text-2);
line-height: 22px;
}
.header-user-menu-divider {
height: 1px;
margin: 4px 10px;
background: var(--color-border-2);
}
.header-dropdown-option--profile :deep(.arco-dropdown-option-icon) {
color: rgb(var(--primary-6));
}
.header-dropdown-option--logout {
color: rgb(var(--red-6));
}
.header-dropdown-option--logout :deep(.arco-dropdown-option-icon) {
color: var(--color-text-3);
}
.header-actions :deep(.arco-dropdown-list-wrapper) {
min-width: 168px;
}
.admin-content {
padding: 14px;
background: #f2f4f8;

@ -138,10 +138,30 @@ const totalPendingTodoCount = computed(() => pendingActivityCount.value)
const pendingActivityItems = computed(() => stats.value.pending_audits?.activities.items ?? [])
const hasPendingTodoRows = computed(() => pendingActivityItems.value.length > 0)
const dashOverviewSplit = computed(() => isSuperAdmin.value || isVenueAdmin.value)
const todoEmptyPlaceholder = computed(() =>
isVenueAdmin.value ? '暂无已退回活动' : '暂无待审核事项',
)
const todoLineKindLabel = computed(() => (isVenueAdmin.value ? '活动退回' : '活动审核'))
function gotoActivityAuditList() {
void router.push({ path: '/activities', query: { audit_status: 'pending' } })
}
function gotoVenueRejectedActivities() {
void router.push({ path: '/activities', query: { audit_status: 'rejected' } })
}
function gotoTodoFromDashboard() {
if (isVenueAdmin.value) {
gotoVenueRejectedActivities()
} else {
gotoActivityAuditList()
}
}
const ticketGrabVerifyRatePctStr = computed(() => {
const t = stats.value.ticket_grab_schedule_counts
if (!t || t.booked_people <= 0) {
@ -306,7 +326,7 @@ onMounted(async () => {
<!-- 概览参考双卡片 数据统计 | 待办事项 -->
<section class="dash-bundle" aria-label="">
<div v-if="!isVenueAdmin" class="dash-overview-dual" :class="{ 'dash-overview-dual--split': isSuperAdmin }">
<div class="dash-overview-dual" :class="{ 'dash-overview-dual--split': dashOverviewSplit }">
<div class="dash-core-pack">
<article class="dash-metric-card dash-metric-card--core">
<header class="dash-metric-card__head">
@ -326,14 +346,14 @@ onMounted(async () => {
<div class="dash-stat-cell dash-stat-cell--sky">
<div class="dash-stat-cell__value">{{ stats.summary.user_count }}</div>
<div class="dash-stat-cell__label">用户数</div>
<div class="dash-stat-cell__hint">预约用户</div>
<div class="dash-stat-cell__hint">{{ isVenueAdmin ? '预约本场馆活动用户' : '预约用户' }}</div>
</div>
</div>
</div>
</article>
</div>
<article v-if="isSuperAdmin" class="dash-metric-card dash-metric-card--todo">
<article v-if="isSuperAdmin || isVenueAdmin" class="dash-metric-card dash-metric-card--todo">
<header class="dash-metric-card__head dash-metric-card__head--todo">
<div class="dash-metric-card__icon dash-metric-card__icon--todo" aria-hidden="true">
<IconOrderedList />
@ -349,7 +369,7 @@ onMounted(async () => {
</div>
<div v-else class="dash-todo-sheet">
<div v-if="!hasPendingTodoRows" class="dash-todo-sheet--empty-inner">
<span class="dash-todo-placeholder">暂无待审核事项</span>
<span class="dash-todo-placeholder">{{ todoEmptyPlaceholder }}</span>
</div>
<template v-else>
<button
@ -357,9 +377,9 @@ onMounted(async () => {
:key="'pa-' + a.id"
type="button"
class="dash-todo-line"
@click="gotoActivityAuditList"
@click="gotoTodoFromDashboard"
>
<span class="dash-todo-line__kind">活动审核</span>
<span class="dash-todo-line__kind">{{ todoLineKindLabel }}</span>
<span class="dash-todo-line__name">{{ a.title }}</span>
<span class="dash-todo-line__action">去处理</span>
</button>

@ -3,13 +3,15 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import RichEditorField from '../../components/RichEditorField.vue'
import { IconEye, IconEyeInvisible } from '@arco-design/web-vue/es/icon'
import { buildVerifyPortalPublicUrl } from '../../api/h5Http'
import { http } from '../../api/http'
import { useModalDirtyGuard } from '../../composables/useModalDirtyGuard'
import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
/** 主列表列宽约 1174px勿用全局 3220 撑宽列 */
const ACTIVITY_LIST_SCROLL_X = 1570
const ACTIVITY_LIST_SCROLL_X = 1820
type Venue = {
id: number
@ -23,15 +25,19 @@ type Venue = {
reservation_notice?: string
open_time?: string
}
type CurrentUser = { role: string; venues: Venue[]; full_admin_access?: boolean }
type CurrentUser = { id?: number; role: string; venues: Venue[]; full_admin_access?: boolean }
type Activity = {
id: number
venue_id: number
/** 后台创建人;平台超管代录可为空,仅超管可改此类活动 */
submitted_by?: number | null
title: string
contact_name?: string | null
summary?: string
booking_audience?: string | null
location?: string | null
/** 活动报到集合地点(可选) */
check_in_meeting_point?: string | null
total_quota?: number
start_at?: string
end_at?: string
@ -55,6 +61,7 @@ type Activity = {
reservation_type?: string
specific_time?: string | null
offline_reservation_method?: string | null
ticket_fee_note?: string | null
external_url?: string | null
/** 线上已报名人数H5/统计用 */
registered_count?: number
@ -89,6 +96,295 @@ const btsActivityId = ref<number | null>(null)
const btsModalTitle = ref('上传花絮')
const btsImages = ref<Array<{ type: 'image'; url: string }>>([])
const btsSaving = ref(false)
type BookingDayRow = {
id?: number
session_name: string
session_start_at: string
session_end_at: string
/** 可选;空=不限制开始,仅需在截止前 */
booking_opens_at: string
booking_deadline_at: string
day_quota: number
/** 有值时 H5 场次「人数限制」展示此处文案,否则展示名额数字 */
quota_note?: string
booked_count?: number
}
const bookingModalVisible = ref(false)
const bookingSaving = ref(false)
const bookingActivityRef = ref<Activity | null>(null)
const bookingForm = reactive({
booking_audience: 'both' as 'individual' | 'group' | 'both',
min_people_per_order: 1,
max_people_per_order: 10,
days: [] as BookingDayRow[],
})
function defaultSessionTimesForActivity(act: Activity | null): { start: string; end: string; deadline: string } {
const d = act?.start_at
? formatYmdInShanghai(String(act.start_at))
: new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
return {
start: `${d} 09:00:00`,
end: `${d} 11:00:00`,
deadline: `${d} 08:30:00`,
}
}
function addBookingDayRow() {
const act = bookingActivityRef.value
const { start, end, deadline } = defaultSessionTimesForActivity(act)
bookingForm.days.push({
session_name: `场次 ${bookingForm.days.length + 1}`,
session_start_at: start,
session_end_at: end,
booking_opens_at: '',
booking_deadline_at: deadline,
day_quota: 30,
quota_note: '',
booked_count: 0,
})
}
function removeBookingDayRow(index: number) {
const row = bookingForm.days[index]
if (row?.booked_count && row.booked_count > 0) {
Message.warning('该场次已有预约,无法删除')
return
}
bookingForm.days.splice(index, 1)
}
function normalizeBookingDayTime(raw: unknown): string {
const s = String(raw ?? '').trim().replace('T', ' ')
if (!s) return ''
return s.length >= 19 ? s.slice(0, 19) : s
}
async function openBookingModal(row: Activity) {
if (!assertCanEditActivityRow(row, '配置场次')) return
if (row.reservation_type !== 'online') {
Message.warning('仅「需要报名」方式可配置场次')
return
}
bookingActivityRef.value = row
bookingSaving.value = false
try {
const { data } = await http.get(`/activities/${row.id}/booking-settings`)
bookingForm.booking_audience = (data?.booking_audience as 'individual' | 'group' | 'both') || 'both'
bookingForm.min_people_per_order = Math.max(1, Number(data?.min_people_per_order) || 1)
bookingForm.max_people_per_order = Math.max(
bookingForm.min_people_per_order,
Number(data?.max_people_per_order) || Math.max(10, bookingForm.min_people_per_order),
)
const days = Array.isArray(data?.days) ? data.days : []
bookingForm.days = days.map((d: Record<string, unknown>) => {
const nid = Number(d.id)
return {
id: Number.isFinite(nid) && nid > 0 ? nid : undefined,
session_name: String(d.session_name ?? ''),
session_start_at: normalizeBookingDayTime(d.session_start_at),
session_end_at: normalizeBookingDayTime(d.session_end_at),
booking_opens_at: normalizeBookingDayTime(d.booking_opens_at),
booking_deadline_at: normalizeBookingDayTime(d.booking_deadline_at),
day_quota: Math.max(0, Number(d.day_quota) || 0),
quota_note: String(d.quota_note ?? ''),
booked_count: Math.max(0, Number(d.booked_count) || 0),
}
})
if (bookingForm.days.length === 0) {
addBookingDayRow()
}
bookingModalVisible.value = true
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '加载场次失败')
}
}
async function saveBookingSettings() {
const act = bookingActivityRef.value
if (!act) return
if (bookingForm.days.length === 0) {
Message.warning('请至少添加一个场次')
return
}
for (let i = 0; i < bookingForm.days.length; i++) {
const d = bookingForm.days[i]
if (!d.session_name.trim()) {
Message.warning(`${i + 1} 行请填写场次名称`)
return
}
if (!d.session_start_at || !d.session_end_at || !d.booking_deadline_at) {
Message.warning(`${i + 1} 行请填写场次开始、结束与预约截止时间`)
return
}
if (!d.day_quota || d.day_quota < 1) {
Message.warning(`${i + 1} 行预约名额须≥1`)
return
}
}
if (
bookingForm.booking_audience !== 'individual'
&& bookingForm.max_people_per_order < bookingForm.min_people_per_order
) {
Message.warning('每单最多人数不能小于最少人数')
return
}
const payload: Record<string, unknown> = {
booking_audience: bookingForm.booking_audience,
days: bookingForm.days.map((d) => {
const row: Record<string, unknown> = {
session_name: d.session_name.trim(),
session_start_at: d.session_start_at,
session_end_at: d.session_end_at,
booking_deadline_at: d.booking_deadline_at,
day_quota: d.day_quota,
quota_note: (d.quota_note || '').trim() || null,
}
const opens = (d.booking_opens_at || '').trim()
row.booking_opens_at = opens || null
if (d.id && d.id > 0) {
row.id = d.id
}
return row
}),
}
if (bookingForm.booking_audience !== 'individual') {
payload.min_people_per_order = bookingForm.min_people_per_order
payload.max_people_per_order = bookingForm.max_people_per_order
}
bookingSaving.value = true
try {
await http.put(`/activities/${act.id}/booking-settings`, payload)
Message.success('场次已保存')
bookingModalVisible.value = false
await loadAll()
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '保存失败')
} finally {
bookingSaving.value = false
}
}
type ActVerifyCredRow = {
id: number
venue_id: number
venue_name?: string
username: string
password_plain?: string | null
note?: string | null
created_at?: string
}
const actVerifyVisible = ref(false)
const actVerifyLoading = ref(false)
const actVerifyActivityId = ref<number | null>(null)
const actVerifyActivityTitle = ref('')
const actVerifyVenueName = ref('')
const actVerifyUrl = ref('')
const actVerifyCreds = ref<ActVerifyCredRow[]>([])
const actVerifyCredForm = reactive({ username: '', password: '', note: '' })
const actVerifyCredSaving = ref(false)
const actPasswordReveal = ref<Record<number, boolean>>({})
const ACT_VERIFY_PASSWORD_HINT = '至少 6 位,最长 72 位'
function validateActivityVerifyPassword(pw: string): string | null {
if (pw.length < 6) return '密码至少 6 位'
if (pw.length > 72) return '密码最长 72 位'
return null
}
async function loadActVerifyData(activityId: number) {
const { data } = await http.get<{ verify_portal_code: string; credentials: ActVerifyCredRow[] }>(
`/activities/${activityId}/verify-portal`,
)
actVerifyUrl.value = buildVerifyPortalPublicUrl(data.verify_portal_code)
actVerifyCreds.value = data.credentials || []
}
async function openActivityVerify(row: Activity) {
if (!assertCanOpenActivityVerify(row)) return
if (row.reservation_type !== 'online') {
Message.warning('仅「需要报名」的活动可配置专用核销')
return
}
actVerifyActivityId.value = row.id
actVerifyActivityTitle.value = row.title || ''
actVerifyVenueName.value = row.venue?.name || ''
actPasswordReveal.value = {}
actVerifyVisible.value = true
actVerifyLoading.value = true
actVerifyCredForm.username = ''
actVerifyCredForm.password = ''
actVerifyCredForm.note = ''
try {
await loadActVerifyData(row.id)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '加载核销配置失败')
actVerifyVisible.value = false
} finally {
actVerifyLoading.value = false
}
}
function copyActVerifyUrl() {
void navigator.clipboard.writeText(actVerifyUrl.value)
Message.success('核销链接已复制')
}
function toggleActPasswordReveal(id: number) {
actPasswordReveal.value = { ...actPasswordReveal.value, [id]: !actPasswordReveal.value[id] }
}
async function addActVerifyCredential() {
if (!canEditActivityVerifyCredentials()) return
if (!actVerifyActivityId.value) return
if (!actVerifyCredForm.username.trim() || !actVerifyCredForm.password) {
Message.warning('请填写用户名与密码')
return
}
const pwdErr = validateActivityVerifyPassword(actVerifyCredForm.password)
if (pwdErr) {
Message.warning(pwdErr)
return
}
actVerifyCredSaving.value = true
try {
await http.post(`/activities/${actVerifyActivityId.value}/verify-credentials`, {
username: actVerifyCredForm.username.trim(),
password: actVerifyCredForm.password,
note: actVerifyCredForm.note.trim() || undefined,
})
Message.success('已添加')
actVerifyCredForm.username = ''
actVerifyCredForm.password = ''
actVerifyCredForm.note = ''
await loadActVerifyData(actVerifyActivityId.value)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '添加失败')
} finally {
actVerifyCredSaving.value = false
}
}
async function removeActVerifyCredential(row: ActVerifyCredRow) {
if (!canEditActivityVerifyCredentials()) return
if (!actVerifyActivityId.value) return
try {
await http.delete(`/activities/${actVerifyActivityId.value}/verify-credentials/${row.id}`)
Message.success('已删除')
actVerifyCreds.value = actVerifyCreds.value.filter((c) => c.id !== row.id)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '删除失败')
}
}
const isCreate = ref(true)
const editId = ref<number | null>(null)
const formBaseline = ref('')
@ -144,11 +440,15 @@ function formatActivityTableDateRange(record: Activity): string {
const form = reactive({
venue_id: undefined as number | undefined,
reservation_type: 'phone' as 'phone' | 'wechat_mp' | 'offline_visit' | 'none',
/** 需要报名 online / 无需报名 none / 或自定义文案(仅展示,与 online 逻辑不同) */
reservation_type: 'online',
is_hot: false,
/** 门票说明:免费/收费,提交至 offline_reservation_method */
ticket_note: 'free' as 'free' | 'paid',
/** 收费说明:仅门票为收费时使用,提交 ticket_fee_note */
fee_note: '',
location: '',
check_in_meeting_point: '',
lat: undefined as number | undefined,
lng: undefined as number | undefined,
specific_time: '',
@ -321,6 +621,56 @@ function isSuperAdmin() {
return currentUser.value?.full_admin_access === true
}
/** 非超管仅能维护本人提交的活动 */
function canEditActivityRow(row: Activity): boolean {
if (isSuperAdmin()) return true
const sid = row.submitted_by
if (sid == null) return false
return Number(sid) === Number(currentUser.value?.id)
}
/** 场馆管理员可查看绑定场馆所举办活动的核销信息(只读),不要求本人创建 */
function canViewActivityVerifyAsVenueAdmin(row: Activity): boolean {
if (!isVenueAdmin()) return false
const vid = row.venue_id
if (vid == null) return false
return (currentUser.value?.venues ?? []).some((v) => Number(v.id) === Number(vid))
}
/** 打开核销管理:创建人/超管,或(场馆管理员且活动场馆已绑定) */
function canOpenActivityVerify(row: Activity): boolean {
return canEditActivityRow(row) || canViewActivityVerifyAsVenueAdmin(row)
}
function assertCanOpenActivityVerify(row: Activity): boolean {
if (canOpenActivityVerify(row)) return true
if (isVenueAdmin()) {
Message.warning('仅可查看已绑定场馆举办的活动核销信息')
} else {
Message.warning(
row.submitted_by == null
? '该平台代录的活动仅超级管理员可管理核销'
: '只能管理本人提交的活动核销',
)
}
return false
}
function assertCanEditActivityRow(row: Activity, action: string): boolean {
if (canEditActivityRow(row)) return true
Message.warning(
row.submitted_by == null
? `该平台代录的活动仅超级管理员可${action}`
: `只能${action}本人提交的活动`,
)
return false
}
/** 活动核销账号:场馆管理员仅可查看链接与密码,不可增删改 */
function canEditActivityVerifyCredentials() {
return !isVenueAdmin()
}
function auditStatusLabel(s?: string | null) {
if (s === 'pending') return '待审核'
if (s === 'rejected') return '已退回'
@ -389,11 +739,20 @@ function openAuditActivity(row: Activity) {
async function auditSubmitApprove(markHot: boolean) {
const row = auditActivityRecord.value
if (!row?.id) return
const approvedId = row.id
const openVerifyAfter = isSuperAdmin() && row.reservation_type === 'online'
try {
await http.post(`/activities/${row.id}/audit/approve`, { mark_hot: markHot })
Message.success('审核已通过')
auditActivityVisible.value = false
await loadAll()
if (openVerifyAfter) {
const found = rows.value.find((r) => r.id === approvedId)
if (found) {
await nextTick()
openActivityVerify(found)
}
}
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
}
@ -430,6 +789,7 @@ function activityHasBehindScenesImages(row: Activity): boolean {
}
function openBehindScenesModal(row: Activity) {
if (!assertCanEditActivityRow(row, '上传花絮')) return
btsActivityId.value = row.id
btsModalTitle.value = activityHasBehindScenesImages(row) ? '查看花絮' : '上传花絮'
const raw = Array.isArray(row.behind_scenes_media) ? row.behind_scenes_media : []
@ -686,14 +1046,15 @@ function confirmMapPick() {
}
function reservationTypeLabel(t?: string | null) {
if (t === 'phone') return '电话预约'
if (t === 'wechat_mp') return '公众号预约'
if (t === 'offline_visit') return '线下预约'
if (t === 'none') return '无需预约'
if (t === 'offline') return '线下预约'
if (t === 'other') return '外链跳转'
if (t === 'online') return '线上预约'
return t || '—'
const s = String(t || '').trim()
if (s === 'online') return '需要报名'
if (s === 'none') return '无需报名'
if (s === 'phone') return '电话预约'
if (s === 'wechat_mp') return '公众号预约'
if (s === 'offline_visit' || s === 'offline') return '线下预约'
if (s === 'other') return '外链跳转'
if (!s) return '—'
return s
}
function statNum(n: unknown): string {
@ -748,9 +1109,11 @@ function openCreate() {
editId.value = null
Object.keys(formErrors).forEach((key) => { formErrors[key] = '' })
form.venue_id = isVenueAdmin() ? currentUser.value?.venues?.[0]?.id : venues.value[0]?.id
form.reservation_type = 'phone'
form.reservation_type = 'online'
form.ticket_note = 'free'
form.fee_note = ''
form.location = ''
form.check_in_meeting_point = ''
form.lat = undefined
form.lng = undefined
form.specific_time = ''
@ -775,29 +1138,19 @@ function openCreate() {
}
function openEdit(row: Activity) {
if (!assertCanEditActivityRow(row, '编辑')) return
isCreate.value = false
editId.value = row.id
Object.keys(formErrors).forEach((key) => { formErrors[key] = '' })
form.venue_id = row.venue_id
const t = row.reservation_type || 'phone'
if (t === 'online') {
form.reservation_type = 'phone'
form.ticket_note = 'free'
} else if (t === 'offline') {
form.reservation_type = 'offline_visit'
form.reservation_type = String(row.reservation_type ?? 'online').trim() || 'online'
{
const m = String(row.offline_reservation_method || '')
form.ticket_note = m === 'paid' ? 'paid' : 'free'
} else if (t === 'other') {
form.reservation_type = 'wechat_mp'
form.ticket_note = 'free'
} else if (t === 'phone' || t === 'wechat_mp' || t === 'offline_visit' || t === 'none') {
form.reservation_type = t
form.ticket_note = row.offline_reservation_method === 'paid' ? 'paid' : 'free'
} else {
form.reservation_type = 'phone'
form.ticket_note = 'free'
}
form.fee_note = row.ticket_fee_note || ''
form.location = row.location || ''
form.check_in_meeting_point = row.check_in_meeting_point || ''
form.lat = parseCoord(row.lat)
form.lng = parseCoord(row.lng)
form.specific_time = row.specific_time || ''
@ -939,6 +1292,14 @@ function validateForm(): boolean {
formErrors.location = '请填写活动地点'
isValid = false
}
const rt = String(form.reservation_type || '').trim()
if (!rt) {
formErrors.reservation_type = '请选择或填写报名方式'
isValid = false
} else if (rt.length > 32) {
formErrors.reservation_type = '报名方式最长 32 个字符'
isValid = false
}
if (!form.ticket_note) {
formErrors.ticket_note = '请选择门票说明'
isValid = false
@ -956,10 +1317,13 @@ async function submit() {
const payload = {
venue_id: form.venue_id,
reservation_type: form.reservation_type,
reservation_type: String(form.reservation_type || '').trim(),
location: form.location.trim(),
check_in_meeting_point: form.check_in_meeting_point.trim() || null,
specific_time: form.specific_time.trim() || null,
offline_reservation_method: form.ticket_note === 'paid' ? 'paid' : 'free',
ticket_fee_note:
form.ticket_note === 'paid' ? (form.fee_note.trim() || null) : null,
external_url: null,
title: form.title.trim(),
summary: form.summary.trim() || null,
@ -1017,6 +1381,7 @@ function onPageChange(p: number) {
}
async function toggleStatus(row: Activity) {
if (!assertCanEditActivityRow(row, '上下架')) return
try {
await http.post(`/activities/${row.id}/toggle`)
Message.success('状态已切换')
@ -1027,6 +1392,7 @@ async function toggleStatus(row: Activity) {
}
async function removeActivity(row: Activity) {
if (!assertCanEditActivityRow(row, '删除')) return
try {
await http.delete(`/activities/${row.id}`)
Message.success('删除成功')
@ -1056,11 +1422,16 @@ async function removeActivity(row: Activity) {
>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
<a-select v-model="filters.reservation_type" allow-clear placeholder="报名方式" style="width: 140px">
<a-option value="phone">电话预约</a-option>
<a-option value="wechat_mp">公众号预约</a-option>
<a-option value="offline_visit">线下预约</a-option>
<a-option value="none">无需预约</a-option>
<a-select
v-model="filters.reservation_type"
allow-clear
allow-create
allow-search
placeholder="报名方式"
style="width: 200px"
>
<a-option value="online">需要报名</a-option>
<a-option value="none">无需报名</a-option>
</a-select>
<a-select v-model="filters.is_active" allow-clear placeholder="上架状态" style="width: 130px">
<a-option value="1">上架</a-option>
@ -1144,20 +1515,35 @@ async function removeActivity(row: Activity) {
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="320" :min-width="300" fixed="right" align="left">
<a-table-column title="操作" :width="460" :min-width="420" fixed="right" align="left">
<template #cell="{ record }">
<a-space wrap :size="4" justify="start">
<a-button type="text" @click="openEdit(record)"></a-button>
<a-button v-if="canEditActivityRow(record as Activity)" type="text" @click="openEdit(record)"></a-button>
<a-button
v-if="canEditActivityRow(record as Activity) && (record as Activity).reservation_type === 'online'"
type="text"
@click="openBookingModal(record as Activity)"
>场次设置</a-button>
<a-button
v-if="canOpenActivityVerify(record as Activity) && (record as Activity).reservation_type === 'online'"
type="text"
@click="openActivityVerify(record as Activity)"
>核销管理</a-button>
<template v-if="isSuperAdmin() && (record.audit_status === 'pending' || record.audit_status === 'rejected')">
<a-button type="text" @click="openAuditActivity(record)"></a-button>
</template>
<a-button
v-if="record.schedule_status === 'ended'"
v-if="canEditActivityRow(record as Activity) && record.schedule_status === 'ended'"
type="text"
@click="openBehindScenesModal(record as Activity)"
>{{ activityHasBehindScenesImages(record as Activity) ? '查看花絮' : '上传花絮' }}</a-button>
<a-button type="text" status="warning" @click="toggleStatus(record)">{{ record.is_active ? '' : '' }}</a-button>
<a-popconfirm content="确认删除该活动?" @ok="removeActivity(record)">
<a-button
v-if="canEditActivityRow(record as Activity)"
type="text"
status="warning"
@click="toggleStatus(record)"
>{{ record.is_active ? '下架' : '上架' }}</a-button>
<a-popconfirm v-if="canEditActivityRow(record as Activity)" content="确认删除该活动?" @ok="removeActivity(record)">
<a-button type="text" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
@ -1167,6 +1553,213 @@ async function removeActivity(row: Activity) {
</a-table>
</a-card>
<a-modal
v-model:visible="bookingModalVisible"
:title="bookingActivityRef ? `场次设置 · ${bookingActivityRef.title}` : '场次设置'"
width="1180px"
:body-style="{ maxHeight: '78vh', overflow: 'auto' }"
:esc-to-close="true"
@cancel="bookingModalVisible = false"
>
<template #footer>
<a-button @click="bookingModalVisible = false">关闭</a-button>
<a-button type="primary" :loading="bookingSaving" @click="saveBookingSettings"></a-button>
</template>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="预约对象">
<a-select v-model="bookingForm.booking_audience">
<a-option value="individual">个人</a-option>
<a-option value="group">团体</a-option>
<a-option value="both">个人+团体</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="bookingForm.booking_audience !== 'individual'" :span="8">
<a-form-item label="每单最少人数">
<a-input-number v-model="bookingForm.min_people_per_order" :min="1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="bookingForm.booking_audience !== 'individual'" :span="8">
<a-form-item label="每单最多人数">
<a-input-number v-model="bookingForm.max_people_per_order" :min="1" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<div style="margin-bottom: 8px; color: var(--color-text-3); font-size: 12px">
场次须同一天内预约截止须早于场次开始预约开始可空=截止前任意时刻均可约有值则仅在开始截止内可约名额不可低于已约人数说明填写后H5 场次不展示名额人数而展示该说明
</div>
<a-button long type="outline" style="margin-bottom: 12px" @click="addBookingDayRow"></a-button>
<a-table
:data="bookingForm.days"
:pagination="false"
:bordered="{ cell: true }"
size="small"
:scroll="{ x: 1380 }"
>
<template #columns>
<a-table-column title="场次名称" :width="112">
<template #cell="{ rowIndex }">
<a-input v-model="bookingForm.days[rowIndex].session_name" placeholder="名称" />
</template>
</a-table-column>
<a-table-column title="场次开始" :width="160">
<template #cell="{ rowIndex }">
<a-date-picker
v-model="bookingForm.days[rowIndex].session_start_at"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="场次结束" :width="160">
<template #cell="{ rowIndex }">
<a-date-picker
v-model="bookingForm.days[rowIndex].session_end_at"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="预约开始" :width="164">
<template #title>
<span>预约开始</span>
<span style="color: var(--color-text-3); font-weight: normal; font-size: 12px">可空</span>
</template>
<template #cell="{ rowIndex }">
<a-date-picker
v-model="bookingForm.days[rowIndex].booking_opens_at"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
allow-clear
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="预约截止" :width="160">
<template #cell="{ rowIndex }">
<a-date-picker
v-model="bookingForm.days[rowIndex].booking_deadline_at"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="名额" :width="80">
<template #cell="{ rowIndex }">
<a-input-number v-model="bookingForm.days[rowIndex].day_quota" :min="1" style="width: 100%" />
</template>
</a-table-column>
<a-table-column title="说明" :width="140">
<template #title>
<span>说明</span>
<span style="color: var(--color-text-3); font-weight: normal; font-size: 12px">可选</span>
</template>
<template #cell="{ rowIndex }">
<a-input
v-model="bookingForm.days[rowIndex].quota_note"
placeholder="有值则 H5 显示此说明替代名额数"
allow-clear
/>
</template>
</a-table-column>
<a-table-column title="已约" :width="56">
<template #cell="{ rowIndex }">
{{ bookingForm.days[rowIndex].booked_count ?? 0 }}
</template>
</a-table-column>
<a-table-column title="" :width="68" fixed="right">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" @click="removeBookingDayRow(rowIndex)"></a-button>
</template>
</a-table-column>
</template>
</a-table>
</a-modal>
<a-modal
v-model:visible="actVerifyVisible"
:title="actVerifyActivityTitle ? `核销管理 · ${actVerifyActivityTitle}` : '核销管理'"
width="840px"
:footer="false"
:esc-to-close="true"
>
<a-spin :loading="actVerifyLoading" style="width: 100%">
<a-typography-paragraph type="secondary" style="margin-bottom: 12px">
<template v-if="canEditActivityVerifyCredentials()">
每个活动有<strong>独立核销登录链接</strong>短码仅作用于本活动可为本活动所属场馆添加多组核销账号<strong>场馆后台账号不可</strong>登录核销页活动结束后按规则自动失效
</template>
<template v-else>
您可查看本活动的<strong>核销登录链接</strong>与核销账号密码<strong>场馆后台账号不可</strong>登录核销页如需增删账号请联系平台管理员
</template>
</a-typography-paragraph>
<div v-if="actVerifyVenueName" style="margin-bottom: 12px; color: var(--color-text-2); font-size: 13px">
举办场馆{{ actVerifyVenueName }}
</div>
<a-form layout="vertical">
<a-form-item label="本活动专用核销链接">
<a-space>
<a-input :model-value="actVerifyUrl" readonly style="width: 500px" />
<a-button type="primary" @click="copyActVerifyUrl"></a-button>
</a-space>
</a-form-item>
</a-form>
<template v-if="canEditActivityVerifyCredentials()">
<a-divider orientation="left">添加核销账号</a-divider>
<a-space style="margin-bottom: 8px; flex-wrap: wrap; align-items: flex-start">
<a-input v-model="actVerifyCredForm.username" placeholder="用户名" style="width: 160px" allow-clear />
<a-input-password v-model="actVerifyCredForm.password" placeholder="密码" style="width: 180px" allow-clear />
<a-input v-model="actVerifyCredForm.note" placeholder="备注" style="width: 160px" allow-clear />
<a-button type="primary" :loading="actVerifyCredSaving" @click="addActVerifyCredential"></a-button>
</a-space>
<div style="margin-bottom: 12px; color: var(--color-text-3); font-size: 12px">密码要求{{ ACT_VERIFY_PASSWORD_HINT }}</div>
</template>
<a-table :data="actVerifyCreds" :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>{{
actPasswordReveal[(record as ActVerifyCredRow).id]
? (record as ActVerifyCredRow).password_plain || '—'
: '*****'
}}</span>
<a-button
type="text"
size="mini"
@click="toggleActPasswordReveal((record as ActVerifyCredRow).id)"
>
<IconEye v-if="!actPasswordReveal[(record as ActVerifyCredRow).id]" />
<IconEyeInvisible v-else />
</a-button>
</a-space>
</template>
</a-table-column>
<a-table-column title="备注" data-index="note" />
<a-table-column title="创建时间" data-index="created_at" />
<a-table-column v-if="canEditActivityVerifyCredentials()" title="操作" :width="90">
<template #cell="{ record }">
<a-popconfirm content="确认删除?" @ok="removeActVerifyCredential(record as ActVerifyCredRow)">
<a-button type="text" size="mini" status="danger">删除</a-button>
</a-popconfirm>
</template>
</a-table-column>
</template>
</a-table>
</a-spin>
</a-modal>
<a-modal
v-model:visible="auditActivityVisible"
title="审核活动"
@ -1266,6 +1859,15 @@ async function removeActivity(row: Activity) {
</div>
</div>
<div class="activity-audit-stack">
<div class="activity-audit-stack__label">活动报到集合地点</div>
<div class="activity-audit-stack__body">
<div class="activity-audit-static-text activity-audit-static-text--fill">
{{ auditActivityRecord.check_in_meeting_point?.trim() ? auditActivityRecord.check_in_meeting_point : '—' }}
</div>
</div>
</div>
<div class="activity-audit-stack">
<div class="activity-audit-stack__label">活动图片</div>
<div class="activity-audit-stack__body">
@ -1412,12 +2014,15 @@ async function removeActivity(row: Activity) {
<a-form-item label="具体时间">
<a-input v-model="form.specific_time" placeholder="如:每日 14:0016:00或 活动当日上午" allow-clear />
</a-form-item>
<a-form-item label="报名方式" required :help="formErrors.reservation_type">
<a-select v-model="form.reservation_type">
<a-option value="phone">电话预约</a-option>
<a-option value="wechat_mp">公众号预约</a-option>
<a-option value="offline_visit">线下预约</a-option>
<a-option value="none">无需预约</a-option>
<a-form-item label="报名方式" required>
<a-select
v-model="form.reservation_type"
allow-create
allow-search
placeholder="可选「需要报名」「无需报名」,或输入自定义文案(仅前端展示,最长 32 字)"
>
<a-option value="online">需要报名</a-option>
<a-option value="none">无需报名</a-option>
</a-select>
<template v-if="formErrors.reservation_type" #help>
<span style="color: #f53f3f;">{{ formErrors.reservation_type }}</span>
@ -1432,6 +2037,16 @@ async function removeActivity(row: Activity) {
<span style="color: #f53f3f;">{{ formErrors.ticket_note }}</span>
</template>
</a-form-item>
<a-form-item v-if="form.ticket_note === 'paid'" label="收费说明" class="admin-modal-form__full">
<a-textarea
v-model="form.fee_note"
placeholder="选填。门票为收费时填写,将在 H5 活动详情「门票说明」下方展示。"
:max-length="1000"
allow-clear
show-word-limit
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="标签">
<div class="activity-form-tags">
<div class="activity-form-tags__line">
@ -1506,6 +2121,13 @@ async function removeActivity(row: Activity) {
<span style="color: #f53f3f;">{{ formErrors.location }}</span>
</template>
</a-form-item>
<a-form-item label="活动报到集合地点" class="admin-modal-form__full">
<a-input
v-model="form.check_in_meeting_point"
placeholder="可选,报到或集合的具体位置(如:××馆南门集合)"
allow-clear
/>
</a-form-item>
<a-form-item label="活动图片" class="admin-modal-form__full">
<div class="activity-cover-carousel-wrap">
<div class="activity-cover-carousel-row__col">
@ -1878,4 +2500,3 @@ async function removeActivity(row: Activity) {
padding-right: 6px;
}
</style>

@ -20,6 +20,7 @@ type MeUser = {
role?: string
venues?: MeVenue[]
auth_mode?: string
portal_kind?: string
event_title?: string
username?: string
full_admin_access?: boolean
@ -95,6 +96,7 @@ const todayStr = () => {
}
const TICKET_GRAB_KIND = 'ticket_grab'
const PORTAL_KIND_ACTIVITY = 'activity'
function headcountText(r: ReservationRow) {
const n = r.ticket_count
@ -134,6 +136,39 @@ const todayStatsDisplay = computed(() => {
const meUser = ref<MeUser | null>(null)
/** 专用核销登录activity = 普通活动ticket_grab = 抢票null = 后台管理员账号 */
const verifyPortalKind = computed(() => {
const u = meUser.value
if (u?.auth_mode === 'verify_portal' || u?.role === 'verify_portal') {
return u.portal_kind === PORTAL_KIND_ACTIVITY ? PORTAL_KIND_ACTIVITY : TICKET_GRAB_KIND
}
return null
})
const scanHintSub = computed(() => {
if (verifyPortalKind.value === PORTAL_KIND_ACTIVITY) {
return '仅展示并核销「活动日为今天」的普通活动预约。'
}
if (verifyPortalKind.value === TICKET_GRAB_KIND) {
return '仅展示并核销「入馆日为今天」的抢票预约。'
}
return '展示今日可核销的预约(普通活动与抢票,详见列表)。'
})
const todayListModalTitle = computed(() => {
if (verifyPortalKind.value === PORTAL_KIND_ACTIVITY) return '今日活动预约'
if (verifyPortalKind.value === TICKET_GRAB_KIND) return '今日抢票信息'
return '今日预约'
})
const todayListEmptyText = computed(() => {
if (verifyPortalKind.value === PORTAL_KIND_ACTIVITY) return '今日暂无活动预约记录'
if (verifyPortalKind.value === TICKET_GRAB_KIND) return '今日暂无抢票预约记录'
return '今日暂无预约记录'
})
const todayListBtnText = computed(() => `查看${todayListModalTitle.value}`)
/** 登录账号可核销的场馆名称(用于页头展示) */
const venueHeadline = computed(() => {
const u = meUser.value
@ -152,12 +187,18 @@ const venueHeadline = computed(() => {
return names.join('、')
})
function mapPortalMe(data: { username?: string; event_title?: string; venue?: MeVenue | null }): MeUser {
function mapPortalMe(data: {
username?: string
event_title?: string
venue?: MeVenue | null
portal_kind?: string
}): MeUser {
return {
auth_mode: 'verify_portal',
role: 'verify_portal',
username: data.username,
event_title: data.event_title,
portal_kind: data.portal_kind,
venues: data.venue ? [data.venue] : [],
}
}
@ -223,7 +264,14 @@ async function loadTodayList() {
}
const data = listOutcome.value.data
const raw = Array.isArray(data) ? data : []
todayList.value = raw.filter((r: ReservationRow) => r.reservation_kind === TICKET_GRAB_KIND)
const pk = verifyPortalKind.value
if (pk === PORTAL_KIND_ACTIVITY) {
todayList.value = raw.filter((r: ReservationRow) => r.reservation_kind !== TICKET_GRAB_KIND)
} else if (pk === TICKET_GRAB_KIND) {
todayList.value = raw.filter((r: ReservationRow) => r.reservation_kind === TICKET_GRAB_KIND)
} else {
todayList.value = raw
}
if (sumOutcome.status === 'fulfilled') {
todaySummary.value = sumOutcome.value.data
} else {
@ -249,16 +297,7 @@ async function fetchPreview(token: string) {
preview.value = null
try {
const { data } = await h5Http.get('/reservations/preview', { params: { qr_token: t } })
const res = data?.reservation as ReservationRow | undefined
if (res && res.reservation_kind !== TICKET_GRAB_KIND) {
preview.value = {
...data,
can_verify: false,
verify_block_reason: data?.verify_block_reason ?? '当前核销入口仅展示抢票预约,不包含普通活动预约。',
}
} else {
preview.value = data
}
preview.value = data
detailVisible.value = true
closeCamera()
} catch (error: any) {
@ -446,13 +485,13 @@ watch(listVisible, (open) => {
if (open) loadTodayList()
})
onMounted(() => {
onMounted(async () => {
if (!localStorage.getItem(H5_TOKEN_KEY)) {
router.replace(loginPath.value)
return
}
loadMe()
loadTodayList()
await loadMe()
void loadTodayList()
})
onBeforeUnmount(() => {
@ -493,9 +532,9 @@ onBeforeUnmount(() => {
<span>扫码核销</span>
</button>
<p class="m-scan-hint">将打开手机摄像头对准用户预约二维码即可识别</p>
<p class="m-scan-hint m-scan-hint--sub">仅展示并核销入馆日为今天的抢票预约</p>
<p class="m-scan-hint m-scan-hint--sub">{{ scanHintSub }}</p>
<a-button long class="m-scan-secondary" @click="listVisible = true">查看今日抢票信息</a-button>
<a-button long class="m-scan-secondary" @click="listVisible = true">{{ todayListBtnText }}</a-button>
</main>
<!-- 摄像头 -->
@ -553,9 +592,37 @@ onBeforeUnmount(() => {
{{ formatDateTimeZh(preview.reservation.verified_at) }}
</a-descriptions-item>
</a-descriptions>
<a-alert v-else type="warning" style="margin-bottom: 12px">
当前核销入口仅展示抢票预约普通活动预约请使用其它渠道
</a-alert>
<a-descriptions v-else :column="1" size="small" class="m-verify-res-desc">
<a-descriptions-item label="活动名称">{{ preview.reservation.activity?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="预约场馆">{{ preview.reservation.venue?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item
v-if="(preview.reservation.activity_day?.session_name || '').trim() || (preview.reservation.activity_day?.time_range_text || '').trim()"
label="场次"
>
{{
[preview.reservation.activity_day?.session_name, preview.reservation.activity_day?.time_range_text]
.filter((x) => x && String(x).trim())
.join(' ')
|| '—'
}}
</a-descriptions-item>
<a-descriptions-item
v-else-if="preview.reservation.activity_day?.activity_date"
label="活动日"
>
{{ preview.reservation.activity_day.activity_date }}
</a-descriptions-item>
<a-descriptions-item label="预约信息">{{ bookerVerifyLine(preview.reservation) }}</a-descriptions-item>
<a-descriptions-item
v-if="(preview.reservation.id_card || '').trim() !== ''"
label="证件号码"
>
{{ preview.reservation.id_card }}
</a-descriptions-item>
<a-descriptions-item v-if="preview.reservation.verified_at" label="核销时间">
{{ formatDateTimeZh(preview.reservation.verified_at) }}
</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
</a-modal>
@ -563,7 +630,7 @@ onBeforeUnmount(() => {
<!-- 今日报名列表 -->
<a-modal
v-model:visible="listVisible"
title="今日抢票信息"
:title="todayListModalTitle"
:footer="false"
width="min(100%, 440px)"
@open="loadTodayList"
@ -573,7 +640,7 @@ onBeforeUnmount(() => {
<strong>{{ todayStatsDisplay.verified_orders }}</strong>
</div>
<a-spin :loading="listLoading" style="width: 100%; min-height: 80px">
<a-empty v-if="!todayList.length" description="今日暂无抢票预约记录" />
<a-empty v-if="!todayList.length" :description="todayListEmptyText" />
<div v-else class="today-list">
<div v-for="r in todayList" :key="r.id" class="today-item">
<div class="today-row">

@ -143,6 +143,20 @@ 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
}
/** 核销账号表格:是否明文显示密码(按行 id */
const tgPasswordReveal = ref<Record<number, boolean>>({})
@ -1040,6 +1054,11 @@ async function addTgVerifyCredential() {
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`, {
@ -1054,7 +1073,11 @@ async function addTgVerifyCredential() {
tgVerifyCredForm.note = ''
await loadTgVerifyData(tgVerifyEventId.value)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '添加失败')
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
}
@ -1228,19 +1251,22 @@ onMounted(async () => {
</a-form-item>
</a-form>
<a-divider orientation="left">按场馆添加核销账号</a-divider>
<a-space style="margin-bottom: 12px; 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: 140px" 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">
<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" />
@ -1672,6 +1698,14 @@ onMounted(async () => {
</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%;
}

@ -7,7 +7,7 @@ import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { resolvePublicMediaUrl } from '../../utils/mediaUrl'
/** 主列表横向滚动宽度(与各列宽之和大致对齐) */
const VENUE_LIST_SCROLL_X = 2110
const VENUE_LIST_SCROLL_X = 2060
type DictItem = {
id: number
@ -284,6 +284,49 @@ async function removeVenue(row: Venue) {
}
}
const venueExporting = ref(false)
async function exportVenues() {
venueExporting.value = true
try {
const res = await http.get('/venues/export', {
responseType: 'blob',
timeout: 120000,
})
const blob = res.data as Blob
if (blob.type && (blob.type.includes('json') || blob.type.includes('text/html'))) {
const text = await blob.text()
let msg = '导出失败'
try {
const j = JSON.parse(text) as { message?: string }
if (j?.message) msg = j.message
} catch {
// ignore
}
Message.error(msg)
return
}
const cd = res.headers['content-disposition'] as string | undefined
let filename = `场馆导出-${new Date().toISOString().slice(0, 10)}.csv`
if (cd) {
const m = /filename\*=(?:UTF-8'')?([^;]+)|filename="?([^";]+)/i.exec(cd)
const raw = decodeURIComponent((m?.[1] || m?.[2] || '').trim().replace(/^"+|"+$/g, ''))
if (raw) filename = raw
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
Message.success('已导出')
} catch (e: any) {
Message.error(e?.response?.data?.message ?? e?.message ?? '导出失败')
} finally {
venueExporting.value = false
}
}
function listCellOneLine(s?: string | null) {
const t = (s || '').trim()
if (!t) return '-'
@ -929,7 +972,7 @@ onMounted(async () => {
</a-select>
<a-button type="primary" @click="onSearch"></a-button>
<a-button type="primary" @click="openCreate"></a-button>
<!-- <a-button v-if="isSuperAdmin()" @click="exportVenues"></a-button> -->
<a-button v-if="isSuperAdmin()" :loading="venueExporting" @click="exportVenues"></a-button>
</a-space>
</template>
@ -992,7 +1035,7 @@ onMounted(async () => {
<a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '上架' : '下架' }}</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="220" fixed="right" align="left">
<a-table-column title="操作" :width="170" fixed="right" align="left">
<template #cell="{ record }">
<a-space wrap justify="start">
<a-button type="text" @click="openEdit(record)"></a-button>

Loading…
Cancel
Save