master
lion 1 week ago
parent 917670101b
commit ba17f28ab1

@ -5,6 +5,10 @@ import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { clearRichTextStyles } from '../utils/richTextEditor'
const emit = defineEmits<{
ready: [quill: unknown]
}>()
const model = defineModel<string>({ default: '' })
const props = withDefaults(
@ -15,6 +19,8 @@ const props = withDefaults(
fieldKey: string
/** 可视化区域最小高度px */
minHeight?: number
/** 图片上传说明(显示在编辑器上方) */
uploadHint?: string
}>(),
{ minHeight: 180 },
)
@ -54,6 +60,10 @@ onMounted(() => {
})
})
function onEditorReady(quill: unknown) {
emit('ready', quill)
}
function toggleSource() {
if (sourceMode.value) {
model.value = textareaLocal.value
@ -93,6 +103,9 @@ watch(
<a-button size="small" @click="toggleSource">{{ sourceMode ? '' : '' }}</a-button>
<a-button size="small" @click="onClearStyles"></a-button>
</a-space>
<a-typography-text v-if="uploadHint" type="secondary" style="display: block; margin-bottom: 8px">
{{ uploadHint }}
</a-typography-text>
<a-textarea
v-if="sourceMode"
v-model="textareaLocal"
@ -107,6 +120,7 @@ watch(
content-type="html"
theme="snow"
:options="mergedEditorOptions"
@ready="onEditorReady"
/>
</div>
</template>

@ -5,6 +5,9 @@ export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 1024 * 1024
export const ADMIN_IMAGE_RECOMMEND_LABEL =
'图片尺寸推荐 1200×600图片大小不超过 1MB'
/** 富文本编辑器内插入图片的提示 */
export const ADMIN_RICH_EDITOR_IMAGE_HINT = '详情内插入的图片单张不超过 1MB'
/** @returns 可提示用户的中文错误文案;未超限或非图片返回 null */
export function adminUploadImageTooLargeMessage(file: File): string | null {
if (!file.type.startsWith('image/')) return null

@ -7,7 +7,7 @@ import { buildUnifiedActivityVerifyLoginUrl } from '../../api/h5Http'
import { http } from '../../api/http'
import { useModalDirtyGuard } from '../../composables/useModalDirtyGuard'
import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard'
import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits'
import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL, ADMIN_RICH_EDITOR_IMAGE_HINT } from '../../utils/adminMediaLimits'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx'
import { ymdFromDateValue } from '../../utils/datetime'
@ -210,6 +210,22 @@ const auditInlineVerifyPin = ref('')
watch(auditActivityVisible, (v) => {
if (!v) {
auditVerifySetupPhase.value = false
auditActivityRecord.value = null
auditBookingPreview.value = []
auditBookingAudienceLabel.value = ''
auditActivityLogs.value = []
auditApproveRemark.value = ''
auditRejectRemark.value = ''
auditInlineVerifyUrl.value = ''
auditInlineVerifyPin.value = ''
}
})
watch(visible, (v) => {
if (!v) {
resetActivityEditForm()
editDetailLoading.value = false
resetEditors()
}
})
const btsVisible = ref(false)
@ -288,6 +304,9 @@ const btsModalBodyStyle = {
boxSizing: 'border-box' as const,
}
const editorRenderKey = ref(0)
/** 避免快速切换编辑/审核时旧请求覆盖新数据 */
let activityEditLoadSeq = 0
let activityAuditLoadSeq = 0
const mediaPreviewVisible = ref(false)
const mediaPreviewType = ref<'image' | 'video'>('image')
const mediaPreviewUrl = ref('')
@ -484,8 +503,8 @@ const bookingForm = reactive({
days: [] as BookingDayRow[],
})
/** 编辑活动弹窗内加载场次预约设置 */
const activityModalBookingLoading = ref(false)
/** 编辑活动弹窗内加载详情与场次 */
const editDetailLoading = ref(false)
/** 已结束时默认冻结场次提交(防误操作);超级管理员仍可编辑并随活动一并保存场次 */
const canSaveSessionsWithActivity = computed(
@ -604,38 +623,65 @@ function sessionDayDisabledDate(cell?: unknown): boolean {
return curYmd < startYmd || curYmd > endYmd
}
async function loadBookingForEditActivity(activityId: number) {
activityModalBookingLoading.value = true
try {
const { data } = await http.get(`/activities/${activityId}/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)
const dq = Number(d.day_quota)
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: Number.isFinite(dq) && dq >= 1 ? dq : undefined,
quota_note: String(d.quota_note ?? ''),
booked_count: Math.max(0, Number(d.booked_count) || 0),
}
})
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '加载场次失败')
throw e
} finally {
activityModalBookingLoading.value = false
function applyBookingSettingsFromResponse(data: Record<string, unknown> | null | undefined) {
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)
const dq = Number(d.day_quota)
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: Number.isFinite(dq) && dq >= 1 ? dq : undefined,
quota_note: String(d.quota_note ?? ''),
booked_count: Math.max(0, Number(d.booked_count) || 0),
}
})
}
function fillEditFormFromActivity(row: Activity) {
form.venue_id = row.venue_id
form.reservation_type = String(row.reservation_type ?? 'online').trim() || 'online'
{
const eff = reservationKindEffective(form.reservation_type)
form.booking_method_note =
String(row.booking_method_note ?? '').trim() ||
(eff === 'online' ? '平台预约' : '')
}
form.fee_note =
row.ticket_fee_note?.trim() ||
(['online', 'none'].includes(reservationKindEffective(form.reservation_type))
? '免费'
: '')
form.age_group = row.age_group || undefined
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.external_url = ''
form.title = row.title
form.contact_name = row.contact_name ?? ''
form.contact_phone = row.contact_phone ?? ''
form.start_at = row.start_at ? ymdFromDateValue(row.start_at) : ''
form.end_at = row.end_at ? ymdFromDateValue(row.end_at) : ''
form.detail_html = row.detail_html || ''
form.cover_image = row.cover_image || ''
form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : []
form.tags = Array.isArray(row.tags) ? [...row.tags] : []
form.sort = row.sort ?? 0
form.summary = row.summary || ''
form.is_active = row.is_active
form.is_hot = isSuperAdmin() ? row.is_hot === true : false
form.display_schedule_status = row.schedule_status
}
function validateBookingFormInternal(): boolean {
@ -839,6 +885,19 @@ function openMediaPreview(type: 'image' | 'video', url: string) {
}
/** Quill toolbar handlers: this.quill is injected by Quill */
async function insertQuillImage(quill: any, file: File) {
const sizeMsg = adminUploadImageTooLargeMessage(file)
if (sizeMsg) {
Message.warning(sizeMsg)
return
}
const url = await uploadFile(file)
const range = quill.getSelection(true)
const index = range?.index ?? Math.max(0, quill.getLength() - 1)
quill.insertEmbed(index, 'image', url, 'user')
quill.setSelection(index + 1, 0)
}
function quillImageHandler(this: { quill: any }) {
const quill = this.quill
const input = document.createElement('input')
@ -847,17 +906,8 @@ function quillImageHandler(this: { quill: any }) {
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const sizeMsg = adminUploadImageTooLargeMessage(file)
if (sizeMsg) {
Message.warning(sizeMsg)
return
}
try {
const url = await uploadFile(file)
const range = quill.getSelection(true)
const index = range?.index ?? Math.max(0, quill.getLength() - 1)
quill.insertEmbed(index, 'image', url, 'user')
quill.setSelection(index + 1, 0)
await insertQuillImage(quill, file)
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '图片上传失败')
}
@ -865,6 +915,32 @@ function quillImageHandler(this: { quill: any }) {
input.click()
}
function onDetailEditorReady(quill: unknown) {
const editor = quill as { root?: HTMLElement; getSelection?: (focus?: boolean) => { index: number } | null; getLength?: () => number; insertEmbed?: (...args: unknown[]) => void; setSelection?: (...args: unknown[]) => void }
const root = editor?.root
if (!root) return
root.addEventListener(
'paste',
(event: Event) => {
const e = event as ClipboardEvent
const items = e.clipboardData?.items
if (!items?.length) return
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item.type.startsWith('image/')) continue
e.preventDefault()
const file = item.getAsFile()
if (!file) return
void insertQuillImage(editor, file).catch((error: any) => {
Message.error(error?.response?.data?.message ?? '图片上传失败')
})
return
}
},
true,
)
}
function quillVideoHandler(this: { quill: any }) {
const quill = this.quill
const input = document.createElement('input')
@ -1131,21 +1207,22 @@ function auditLogActionLabel(action: string): string {
}
async function openAuditActivity(row: Activity, mode: 'audit' | 'view') {
auditActivityRecord.value = row
const effectiveMode =
mode === 'audit' && row.audit_status === 'pending' && isSuperAdmin() ? 'audit' : 'view'
auditModalMode.value = effectiveMode
const loadSeq = ++activityAuditLoadSeq
auditActivityRecord.value = null
auditApproveRemark.value = ''
auditRejectRemark.value = ''
auditVerifySetupPhase.value = false
auditInlineVerifyUrl.value = ''
auditInlineVerifyPin.value = ''
auditModalMode.value =
mode === 'audit' && row.audit_status === 'pending' && isSuperAdmin() ? 'audit' : 'view'
auditActivityVisible.value = true
auditDetailLoading.value = true
auditBookingPreview.value = []
auditBookingAudienceLabel.value = ''
auditActivityLogs.value = []
try {
const detailPromise = http.get<Activity>(`/activities/${row.id}`)
const logPromise = http.get(`/activities/${row.id}/activity-audit-logs`)
const bookingPromise = reservationTypeSupportsSessionSettings(row.reservation_type)
? http.get(`/activities/${row.id}/booking-settings`)
@ -1154,7 +1231,14 @@ async function openAuditActivity(row: Activity, mode: 'audit' | 'view') {
row.reservation_type === 'online' && row.audit_status === 'approved' && canOpenActivityVerify(row)
? http.get(`/activities/${row.id}/verify-portal`)
: Promise.resolve(null)
const [logRes, bookingRes, verifyRes] = await Promise.all([logPromise, bookingPromise, verifyPromise])
const [detailRes, logRes, bookingRes, verifyRes] = await Promise.all([
detailPromise,
logPromise,
bookingPromise,
verifyPromise,
])
if (loadSeq !== activityAuditLoadSeq || !auditActivityVisible.value) return
auditActivityRecord.value = detailRes.data
const logData = logRes.data as { data?: ActivityAuditLogRow[] }
auditActivityLogs.value = logData.data ?? []
if (bookingRes) {
@ -1590,11 +1674,10 @@ async function loadMe() {
}
}
function openCreate() {
suppressReservationTypeBookingReset.value += 1
try {
function resetActivityEditForm() {
isCreate.value = true
editId.value = null
editContextRowSnapshot.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 = 'online'
@ -1624,10 +1707,17 @@ function openCreate() {
bookingForm.min_people_per_order = 1
bookingForm.max_people_per_order = 10
bookingForm.days = []
editContextRowSnapshot.value = null
resetEditors()
captureFormBaseline()
visible.value = true
tagInput.value = ''
formBaseline.value = editFlowSignature()
}
function openCreate() {
suppressReservationTypeBookingReset.value += 1
try {
resetActivityEditForm()
resetEditors()
captureFormBaseline()
visible.value = true
} finally {
void nextTick(() => {
suppressReservationTypeBookingReset.value -= 1
@ -1637,54 +1727,42 @@ function openCreate() {
function openEdit(row: Activity) {
if (!assertCanOpenActivityEditor(row)) return
void openEditWithDetail(row)
}
async function openEditWithDetail(row: Activity) {
const loadSeq = ++activityEditLoadSeq
suppressReservationTypeBookingReset.value += 1
try {
isCreate.value = false
editId.value = row.id
bookingForm.booking_audience = 'both'
bookingForm.min_people_per_order = 1
bookingForm.max_people_per_order = 10
bookingForm.days = []
editContextRowSnapshot.value = row
Object.keys(formErrors).forEach((key) => { formErrors[key] = '' })
form.venue_id = row.venue_id
form.reservation_type = String(row.reservation_type ?? 'online').trim() || 'online'
{
const eff = reservationKindEffective(form.reservation_type)
form.booking_method_note =
String(row.booking_method_note ?? '').trim() ||
(eff === 'online' ? '平台预约' : '')
}
form.fee_note =
row.ticket_fee_note?.trim() ||
(['online', 'none'].includes(reservationKindEffective(form.reservation_type))
? '免费'
: '')
form.age_group = row.age_group || undefined
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.external_url = ''
form.title = row.title
form.contact_name = row.contact_name ?? ''
form.contact_phone = row.contact_phone ?? ''
form.start_at = row.start_at ? ymdFromDateValue(row.start_at) : ''
form.end_at = row.end_at ? ymdFromDateValue(row.end_at) : ''
form.detail_html = row.detail_html || ''
form.cover_image = row.cover_image || ''
form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : []
form.tags = Array.isArray(row.tags) ? [...row.tags] : []
form.sort = row.sort ?? 0
form.summary = row.summary || ''
form.is_active = row.is_active
form.is_hot = isSuperAdmin() ? row.is_hot === true : false
form.display_schedule_status = row.schedule_status
resetEditors()
captureFormBaseline()
visible.value = true
void loadBookingForEditActivity(row.id).catch(() => undefined)
resetActivityEditForm()
resetEditors()
isCreate.value = false
editId.value = row.id
editContextRowSnapshot.value = row
visible.value = true
editDetailLoading.value = true
const detailPromise = http.get<Activity>(`/activities/${row.id}`)
const bookingPromise = reservationTypeSupportsSessionSettings(row.reservation_type)
? http.get(`/activities/${row.id}/booking-settings`)
: Promise.resolve(null)
const [detailRes, bookingRes] = await Promise.all([detailPromise, bookingPromise])
if (loadSeq !== activityEditLoadSeq || !visible.value) return
const activity = detailRes.data
editContextRowSnapshot.value = activity
fillEditFormFromActivity(activity)
if (bookingRes) {
applyBookingSettingsFromResponse(bookingRes.data as Record<string, unknown>)
}
resetEditors()
captureFormBaseline()
} catch (e: any) {
if (loadSeq !== activityEditLoadSeq) return
Message.error(e?.response?.data?.message ?? '加载活动详情失败')
visible.value = false
} finally {
if (loadSeq === activityEditLoadSeq) {
editDetailLoading.value = false
}
void nextTick(() => {
suppressReservationTypeBookingReset.value -= 1
})
@ -2136,7 +2214,7 @@ async function removeActivity(row: Activity) {
<a-table-column title="操作" :width="230" :min-width="210" fixed="right" align="left">
<template #cell="{ record }">
<a-space wrap :size="4" justify="start">
<a-button v-if="canShowActivityEditButton(record as Activity)" type="text" @click="openEdit(record)">{{ activityListEditButtonLabel(record as Activity) }}</a-button>
<a-button v-if="canShowActivityEditButton(record as Activity)" type="text" @click="openEdit(record as Activity)">{{ activityListEditButtonLabel(record as Activity) }}</a-button>
<a-button
v-if="isSuperAdmin() && record.audit_status === 'pending'"
type="text"
@ -2494,7 +2572,7 @@ async function removeActivity(row: Activity) {
:body-style="modalBodyStyle"
:on-before-cancel="confirmDiscardChanges"
>
<a-spin class="activity-edit-spin" style="width: 100%" :loading="activityModalBookingLoading">
<a-spin class="activity-edit-spin" style="width: 100%" :loading="editDetailLoading">
<a-form :model="form" layout="vertical" class="admin-modal-form">
<a-form-item label="活动名称" required :help="formErrors.title">
<a-input v-model="form.title" />
@ -2890,7 +2968,9 @@ async function removeActivity(row: Activity) {
:editor-options="detailEditorOptions"
field-key="activity-detail"
:min-height="280"
:upload-hint="ADMIN_RICH_EDITOR_IMAGE_HINT"
:key="`activity-detail-${editorRenderKey}`"
@ready="onDetailEditorReady"
/>
</a-form-item>
</a-form>

@ -79,7 +79,9 @@ onMounted(async () => {
<template>
<a-card title="系统设置 / 访问量设置">
<a-alert type="info" style="margin-bottom: 16px">
仪表盘总访问量今日访问量展示 <strong>展示数值</strong>= 实际统计 + 调整量实际访问量仍由 H5 访问记录自动累计在此可直接修改展示数值系统会自动换算并保存调整量
仪表盘展示 <strong>展示数值</strong>实际访问量由 H5 自动累计<br />
<strong>总访问量</strong>调整量保存后永久生效不会每天重复叠加之后有新访问时展示值会在你设定的基础上继续累加<br />
<strong>今日访问量</strong>调整量仅在保存当天生效跨天后展示值只统计当日实际访问不再沿用前一天的调整量
</a-alert>
<a-spin :loading="loading">
@ -87,7 +89,7 @@ onMounted(async () => {
<a-form-item label="实际总访问量(只读)">
<a-input-number :model-value="stats?.actual_total ?? 0" disabled style="width: 100%" />
</a-form-item>
<a-form-item label="展示总访问量" extra="保存后仪表盘总访问量显示此数值;后续实际访问增加时,展示值会同步增加。">
<a-form-item label="展示总访问量" extra="调整量永久保存,不会每日重复叠加;有新访问时展示值会在该基础上继续增加。">
<a-input-number
v-model="form.display_total"
:min="0"
@ -105,7 +107,7 @@ onMounted(async () => {
<a-form-item label="实际今日访问量(只读)">
<a-input-number :model-value="stats?.actual_today ?? 0" disabled style="width: 100%" />
</a-form-item>
<a-form-item label="展示今日访问量" extra="保存后仪表盘今日访问量显示此数值;跨天后实际今日统计会归零,展示值 = 新实际值 + 今日调整量。">
<a-form-item label="展示今日访问量" extra="调整量仅当天有效;跨天后仪表盘今日访问量只显示当日实际访问。">
<a-input-number
v-model="form.display_today"
:min="0"

Loading…
Cancel
Save