master
lion 1 month ago
parent 6a7407aceb
commit 80c385eab6

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
@ -24,6 +24,35 @@ const minHeightPx = computed(() => `${props.minHeight}px`)
const sourceMode = ref(false)
const quillKey = ref(0)
const textareaLocal = ref('')
/** 供 Quill 外链/公式气泡定位:在弹窗内时使用 modal body 作边界,避免相对 body 校正后仍被弹窗 overflow 裁切 */
const rootEl = ref<HTMLElement | null>(null)
const tooltipBoundsEl = ref<HTMLElement | null>(null)
const mergedEditorOptions = computed(() => {
const base = { ...props.editorOptions }
const b = tooltipBoundsEl.value
if (b) {
return { ...base, bounds: b }
}
return base
})
function resolveTooltipBounds(): HTMLElement | null {
const el = rootEl.value
if (!el) return null
return (
el.closest<HTMLElement>('.arco-modal-body') ??
el.closest<HTMLElement>('.arco-drawer-body') ??
el
)
}
onMounted(() => {
void nextTick(() => {
tooltipBoundsEl.value = resolveTooltipBounds()
quillKey.value += 1
})
})
function toggleSource() {
if (sourceMode.value) {
@ -47,17 +76,19 @@ function onClearStyles() {
/** 父级在打开新增/编辑弹窗时递增 fieldKey需退出源码模式并重建 Quill避免残留上一表单内容 */
watch(
() => props.fieldKey,
(_next, prev) => {
async (_next, prev) => {
if (prev === undefined) return
sourceMode.value = false
textareaLocal.value = ''
await nextTick()
tooltipBoundsEl.value = resolveTooltipBounds()
quillKey.value += 1
},
)
</script>
<template>
<div class="rich-editor-field">
<div ref="rootEl" class="rich-editor-field">
<a-space style="margin-bottom: 8px; flex-wrap: wrap">
<a-button size="small" @click="toggleSource">{{ sourceMode ? '' : '' }}</a-button>
<a-button size="small" @click="onClearStyles"></a-button>
@ -75,7 +106,7 @@ watch(
v-model:content="model"
content-type="html"
theme="snow"
:options="editorOptions"
:options="mergedEditorOptions"
/>
</div>
</template>
@ -92,9 +123,18 @@ watch(
.rich-editor-field :deep(.ql-container) {
min-height: v-bind(minHeightPx);
overflow: visible;
}
.rich-editor-field :deep(.ql-editor) {
min-height: 140px;
}
/* Quill Snow 外链气泡:中文标签(覆盖主题默认 Enter link / Save */
.rich-editor-field :deep(.ql-snow .ql-tooltip[data-mode='link']::before) {
content: '输入外链';
}
.rich-editor-field :deep(.ql-snow .ql-tooltip.ql-editing a.ql-action::after) {
content: '保存';
}
</style>

@ -140,9 +140,9 @@ type BookingDayRow = {
/** 可选;空=不限制开始,仅需在截止前 */
booking_opens_at: string
booking_deadline_at: string
/** 未填写时表格为空,提交前须填 */
/** 未填写时表格为空;需预约活动时提交前必填,其余性质选填 */
day_quota: number | undefined
/** 有值时 H5 场次「人数限制」展示此处文案,否则展示可预约人数数字 */
/** 有值时 H5 场次「参与人数」优先展示此处说明,否则展示数字 */
quota_note?: string
booked_count?: number
}
@ -267,7 +267,14 @@ const activityBookingStepIntro = computed(() => {
is_active: !!form.is_active,
}
const datePart = formatActivityTableDateRange(r)
return `本次活动日期 ${datePart},请逐项手动填写各场次的场次开始、场次结束${normalizeReservationKind(form.reservation_type) === 'online' ? '、预约开始与预约截止' : ''}及可预约人数;保存时将一并提交活动信息与场次。`
const rt = normalizeReservationKind(form.reservation_type)
if (rt === 'none' || rt === 'paid_study') {
return `本次活动日期 ${datePart},请逐项手动填写各场次信息`
}
if (rt === 'online') {
return `本次活动日期 ${datePart},请逐项手动填写各场次的场次开始、场次结束、预约开始与预约截止及可预约人数(必填)。保存时将一并提交活动信息与场次。`
}
return `本次活动日期 ${datePart},请逐项手动填写各场次信息`
})
/** 审核弹窗内「提交人」展示文案 */
@ -421,19 +428,29 @@ const showBookingAudienceFields = computed(
)
const sessionRulesHint = computed(() => {
const rt = normalizeReservationKind(form.reservation_type)
if (rt !== 'online') {
return ''
}
const groupNote =
'团体每单最少人数以及团体每单最多人数不是场次的可预约人数,仅限制每场次团体用户可预约的人数;'
const sameDay = '每场次的开始时间与结束时间须为同一天内;'
if (normalizeReservationKind(form.reservation_type) === 'online') {
return `${groupNote}${sameDay}场次开始、场次结束、预约开始、预约截止时间须填写;预约截止时间须早于场次开始时间;预约开始不得晚于预约截止;更改可预约人数不可低于已约人数;`
}
return `${groupNote}${sameDay}场次开始、场次结束须填写;更改可预约人数不可低于已约人数;`
return `${groupNote}${sameDay}场次开始、场次结束、预约开始、预约截止时间须填写;预约截止时间须早于场次开始时间;预约开始不得晚于预约截止;更改可预约人数不可低于已约人数;`
})
const sessionTableScrollX = computed(() =>
showSessionBookingWindowColumns.value ? 1470 : 920,
showSessionBookingWindowColumns.value ? 1470 : 940,
)
/** 场次表格/审核预览中「人数」列标题:需预约为可预约人数,其余为参加人数 */
const sessionQuotaColumnTitle = computed(() =>
normalizeReservationKind(form.reservation_type) === 'online' ? '可预约人数' : '参加人数',
)
function auditSessionQuotaColumnTitle(rt?: string | null): string {
return normalizeReservationKind(rt) === 'online' ? '可预约人数' : '参加人数'
}
const activityModalTitle = computed(() => (isCreate.value ? '新增活动' : '编辑活动'))
function addBookingDayRow() {
@ -609,18 +626,33 @@ function validateBookingFormInternal(): boolean {
}
}
const booked = Math.max(0, Number(d.booked_count) || 0)
const minQ = Math.max(1, booked)
const quotaRaw = d.day_quota
if (quotaRaw === undefined || quotaRaw === null || !Number.isFinite(Number(quotaRaw))) {
Message.warning(`${i + 1} 行请填写可预约人数`)
return false
}
const quotaNum = Math.floor(Number(quotaRaw))
if (quotaNum < minQ) {
Message.warning(
`${i + 1} 行可预约人数须≥${minQ}${booked > 0 ? `(已约 ${booked} 人)` : ''}`,
)
return false
if (rt === 'online') {
const minQ = Math.max(1, booked)
if (quotaRaw === undefined || quotaRaw === null || !Number.isFinite(Number(quotaRaw))) {
Message.warning(`${i + 1} 行请填写可预约人数`)
return false
}
const quotaNum = Math.floor(Number(quotaRaw))
if (quotaNum < minQ) {
Message.warning(
`${i + 1} 行可预约人数须≥${minQ}${booked > 0 ? `(已约 ${booked} 人)` : ''}`,
)
return false
}
} else if (quotaRaw !== undefined && quotaRaw !== null) {
if (!Number.isFinite(Number(quotaRaw))) {
Message.warning(`${i + 1} 行参加人数须为有效数字`)
return false
}
const quotaNum = Math.floor(Number(quotaRaw))
const minAtt = Math.max(0, booked)
if (quotaNum < minAtt) {
Message.warning(
`${i + 1} 行参加人数须≥${minAtt}${booked > 0 ? `(已约 ${booked} 人)` : ''}`,
)
return false
}
}
}
if (
@ -649,14 +681,21 @@ function buildBookingPayload(): Record<string, unknown> {
rt === 'online'
? String(d.booking_deadline_at || '').trim()
: String(d.booking_deadline_at || '').trim() || start
const dq = d.day_quota
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: deadlineIn || null,
day_quota: Math.floor(Number(d.day_quota)),
quota_note: (d.quota_note || '').trim() || null,
}
if (rt === 'online') {
row.day_quota = Math.floor(Number(dq))
} else if (dq === undefined || dq === null || (typeof dq === 'number' && !Number.isFinite(dq))) {
row.day_quota = null
} else {
row.day_quota = Math.floor(Number(dq))
}
if (rt === 'online') {
const opens = (d.booking_opens_at || '').trim()
row.booking_opens_at = opens || null
@ -2218,7 +2257,7 @@ async function removeActivity(row: Activity) {
data-index="booking_deadline_at"
:width="184"
/>
<a-table-column title="可预约人数" data-index="day_quota" :width="100" />
<a-table-column :title="auditSessionQuotaColumnTitle(auditActivityRecord?.reservation_type)" data-index="day_quota" :width="108" />
<a-table-column title="说明" :width="220" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">
<span>{{ ((record as BookingDayRow).quota_note || '').trim() || '—' }}</span>
@ -2591,7 +2630,7 @@ async function removeActivity(row: Activity) {
{{ activityBookingStepIntro }}
</a-typography-text>
<a-typography-text v-else type="warning" style="display: block; margin-bottom: 12px">
活动已结束本次保存将不更新场次与可预约人数仅活动基本信息
活动已结束本次保存将不更新场次与场次人数信息仅活动基本信息
</a-typography-text>
<a-form v-if="showBookingAudienceFields" layout="vertical">
<a-row :gutter="16">
@ -2620,7 +2659,7 @@ async function removeActivity(row: Activity) {
</a-col>
</a-row>
</a-form>
<div style="margin-bottom: 8px; color: var(--color-text-3); font-size: 12px">
<div v-if="sessionRulesHint" style="margin-bottom: 8px; color: var(--color-text-3); font-size: 12px">
{{ sessionRulesHint }}
</div>
<a-button
@ -2703,12 +2742,16 @@ async function removeActivity(row: Activity) {
/>
</template>
</a-table-column>
<a-table-column title="可预约人数" :width="100">
<a-table-column :title="sessionQuotaColumnTitle" :width="108">
<template #cell="{ rowIndex }">
<a-input-number
v-model="bookingForm.days[rowIndex].day_quota"
:min="Math.max(1, Number(bookingForm.days[rowIndex].booked_count) || 0)"
placeholder="必填"
:min="
showSessionBookingWindowColumns
? Math.max(1, Number(bookingForm.days[rowIndex].booked_count) || 0)
: Math.max(0, Number(bookingForm.days[rowIndex].booked_count) || 0)
"
:placeholder="showSessionBookingWindowColumns ? '必填' : '选填'"
allow-clear
style="width: 100%"
:disabled="!canSaveSessionsWithActivity"
@ -2723,7 +2766,11 @@ async function removeActivity(row: Activity) {
<template #cell="{ rowIndex }">
<a-input
v-model="bookingForm.days[rowIndex].quota_note"
placeholder="可与可预约人数配合说明"
:placeholder="
showSessionBookingWindowColumns
? '可与可预约人数配合说明'
: '可与参加人数配合说明'
"
allow-clear
:disabled="!canSaveSessionsWithActivity"
/>

@ -8,7 +8,7 @@ import { reservationStatusLabel } from '../../utils/reservationStatus'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import * as XLSX from 'xlsx'
const REGISTRATIONS_LIST_SCROLL_X = 1960
const REGISTRATIONS_LIST_SCROLL_X = 2100
type ActivityDayRef = {
id: number
@ -301,7 +301,6 @@ function onPageChange(p: number) {
<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-space>
<div class="reg-export-bar">
<a-select v-model="exportScope" class="reg-export-scope">
@ -411,8 +410,8 @@ function onPageChange(p: number) {
<a-table-column
title="二维码 token"
data-index="qr_token"
:width="220"
:min-width="180"
:width="360"
:min-width="280"
:ellipsis="true"
:tooltip="true"
fixed="right"

@ -7,7 +7,7 @@ import { bookingTypeLabel } from '../../utils/bookingType'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { reservationStatusLabel } from '../../utils/reservationStatus'
const ACTIVITY_VERIFY_LIST_SCROLL_X = 1920
const ACTIVITY_VERIFY_LIST_SCROLL_X = 2140
type ActivityDayRef = {
id: number
@ -95,13 +95,6 @@ function onSearchList() {
void loadRows()
}
function resetListFilters() {
statusFilter.value = 'all'
keyword.value = ''
dateRange.value = []
void loadRows()
}
async function verifyToken() {
if (!tokenInput.value) {
Message.warning('请输入二维码 token')
@ -111,8 +104,7 @@ async function verifyToken() {
try {
await http.post('/reservations/verify', { qr_token: tokenInput.value })
Message.success('核销成功')
tokenInput.value = ''
await loadRows()
window.location.reload()
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '核销失败')
} finally {
@ -126,10 +118,6 @@ onMounted(loadRows)
<template>
<a-card title="活动管理 / 现场核销">
<a-space direction="vertical" fill>
<a-alert>
输入预约二维码 token 进行核销场馆管理员仅可核销自己绑定场馆的预约
</a-alert>
<a-space wrap :size="12">
<a-input v-model="tokenInput" style="width: min(100%, 420px)" placeholder="请输入二维码 token" allow-clear />
<a-button type="primary" :loading="verifying" @click="verifyToken"></a-button>
@ -147,8 +135,6 @@ onMounted(loadRows)
<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="onSearchList"></a-button>
<a-button @click="resetListFilters"></a-button>
<a-button @click="loadRows"></a-button>
</a-space>
</div>
@ -215,8 +201,8 @@ onMounted(loadRows)
<a-table-column
title="二维码 token"
data-index="qr_token"
:width="220"
:min-width="180"
:width="360"
:min-width="280"
:ellipsis="true"
:tooltip="true"
fixed="right"

Loading…
Cancel
Save