diff --git a/src/components/RichEditorField.vue b/src/components/RichEditorField.vue index ec26aa9..42c7160 100644 --- a/src/components/RichEditorField.vue +++ b/src/components/RichEditorField.vue @@ -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({ 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( {{ sourceMode ? '返回可视化' : '查看源代码' }} 一键清除样式 + + {{ uploadHint }} + diff --git a/src/utils/adminMediaLimits.ts b/src/utils/adminMediaLimits.ts index e03e532..2be9bd7 100644 --- a/src/utils/adminMediaLimits.ts +++ b/src/utils/adminMediaLimits.ts @@ -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 diff --git a/src/views/activities/ActivityList.vue b/src/views/activities/ActivityList.vue index e37bf9f..186ede4 100644 --- a/src/views/activities/ActivityList.vue +++ b/src/views/activities/ActivityList.vue @@ -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) => { - 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 | 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) => { + 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(`/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(`/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) + } + 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) {