@ -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 activityModalBooking Loading = ref ( false )
/** 编辑活动弹窗内加载 详情与 场次 */
const editDetail Loading = 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 =" activityModalBooking Loading">
< a -spin class = "activity-edit-spin" style = "width: 100%" :loading =" editDetail Loading">
< 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 - f o r m - i t e m >
< / a - f o r m >