|
|
<script setup lang="ts">
|
|
|
import { nextTick, onMounted, reactive, ref } from 'vue'
|
|
|
import { Message } from '@arco-design/web-vue'
|
|
|
import { http } from '../../api/http'
|
|
|
import RichEditorField from '../../components/RichEditorField.vue'
|
|
|
import { listTableRowIndex } from '../../utils/listTableRowIndex'
|
|
|
import { resolvePublicMediaUrl } from '../../utils/mediaUrl'
|
|
|
|
|
|
/** 主列表横向滚动宽度(与各列宽之和大致对齐) */
|
|
|
const VENUE_LIST_SCROLL_X = 2110
|
|
|
|
|
|
type DictItem = {
|
|
|
id: number
|
|
|
item_label: string
|
|
|
item_value: string
|
|
|
item_remark?: string
|
|
|
}
|
|
|
type Venue = {
|
|
|
id: number
|
|
|
name: string
|
|
|
venue_type?: string
|
|
|
venue_types?: string[]
|
|
|
unit_name?: string
|
|
|
district?: string
|
|
|
ticket_type?: string
|
|
|
appointment_type?: string | null
|
|
|
booking_mode?: string | null
|
|
|
open_mode?: string | null
|
|
|
open_time?: string
|
|
|
reservation_notice?: string
|
|
|
ticket_content?: string
|
|
|
booking_method?: string
|
|
|
visit_form?: string
|
|
|
consultation_hours?: string
|
|
|
/** 预约方式旁展示的二维码多图 */
|
|
|
booking_qr_media?: Array<{ type: 'image'; url: string }>
|
|
|
address?: string
|
|
|
contact_phone?: string | null
|
|
|
/** 后端 decimal 序列化可能为字符串 */
|
|
|
lat?: number | string | null
|
|
|
lng?: number | string | null
|
|
|
cover_image?: string
|
|
|
gallery_media?: Array<{ type: 'image' | 'video'; url: string }>
|
|
|
detail_html?: string
|
|
|
sort?: number
|
|
|
is_active: boolean
|
|
|
is_included_in_stats?: boolean
|
|
|
audit_status?: 'approved' | 'pending' | 'rejected'
|
|
|
audit_remark?: string | null
|
|
|
}
|
|
|
type CurrentUser = {
|
|
|
role: string
|
|
|
full_admin_access?: boolean
|
|
|
}
|
|
|
|
|
|
const loading = ref(false)
|
|
|
const saving = ref(false)
|
|
|
const visible = ref(false)
|
|
|
const mapVisible = ref(false)
|
|
|
const mapLoading = ref(false)
|
|
|
const mapKeyword = ref('')
|
|
|
const mapResults = ref<Array<{ title: string; address: string; lat: number; lng: number }>>([])
|
|
|
const isCreate = ref(false)
|
|
|
const editId = ref<number | null>(null)
|
|
|
const rows = ref<Venue[]>([])
|
|
|
const districtOptions = ref<DictItem[]>([])
|
|
|
const venueTypeOptions = ref<DictItem[]>([])
|
|
|
const appointmentTypeOptions = ref<DictItem[]>([])
|
|
|
const bookingModeOptions = ref<DictItem[]>([])
|
|
|
const openModeOptions = ref<DictItem[]>([])
|
|
|
const ticketTypeOptions = ref<DictItem[]>([])
|
|
|
const currentUser = ref<CurrentUser | null>(null)
|
|
|
const mapContainerRef = ref<HTMLElement | null>(null)
|
|
|
const editorRenderKey = ref(0)
|
|
|
const mediaPreviewVisible = ref(false)
|
|
|
const mediaPreviewType = ref<'image' | 'video'>('image')
|
|
|
const mediaPreviewUrl = ref('')
|
|
|
let mapInstance: any = null
|
|
|
let markerLayer: any = null
|
|
|
const pickedPoint = ref<{ lat: number; lng: number; address: string } | null>(null)
|
|
|
const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 } // 苏州市人民政府
|
|
|
const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' }
|
|
|
|
|
|
// 表单验证错误信息
|
|
|
const formErrors = reactive<Record<string, string>>({
|
|
|
name: '',
|
|
|
venue_types: '',
|
|
|
district: '',
|
|
|
unit_name: '',
|
|
|
ticket_type: '',
|
|
|
booking_mode: '',
|
|
|
open_mode: '',
|
|
|
visit_form: '',
|
|
|
open_time: '',
|
|
|
consultation_hours: '',
|
|
|
contact_phone: '',
|
|
|
address: '',
|
|
|
lat: '',
|
|
|
lng: '',
|
|
|
booking_method: '',
|
|
|
ticket_content: '',
|
|
|
cover_image: '',
|
|
|
gallery_media: '',
|
|
|
detail_html: '',
|
|
|
})
|
|
|
|
|
|
function clearFormErrors() {
|
|
|
Object.keys(formErrors).forEach((key) => {
|
|
|
formErrors[key] = ''
|
|
|
})
|
|
|
}
|
|
|
|
|
|
const filters = reactive({
|
|
|
keyword: '',
|
|
|
district: '',
|
|
|
venue_type: '',
|
|
|
ticket_type: '',
|
|
|
booking_mode: '',
|
|
|
open_mode: '',
|
|
|
appointment_type: '',
|
|
|
/** 预约方式(富文本)是否已填写:all=不限 */
|
|
|
is_active: '' as '' | '1' | '0',
|
|
|
is_included_in_stats: '' as '' | '1' | '0',
|
|
|
})
|
|
|
|
|
|
const pagination = reactive({
|
|
|
current: 1,
|
|
|
pageSize: 10,
|
|
|
total: 0,
|
|
|
})
|
|
|
|
|
|
const rejectVenueId = ref<number | null>(null)
|
|
|
const rejectVenueRemark = ref('')
|
|
|
const rejectVenueVisible = ref(false)
|
|
|
|
|
|
function parseCoord(v: unknown): number | undefined {
|
|
|
if (v === null || v === undefined || v === '') return undefined
|
|
|
const n = typeof v === 'number' ? v : parseFloat(String(v).trim())
|
|
|
return Number.isFinite(n) ? n : undefined
|
|
|
}
|
|
|
|
|
|
/** Quill 工具栏 handlers 里 this.quill 由 Quill 注入 */
|
|
|
function quillImageHandler(this: { quill: any }) {
|
|
|
const quill = this.quill
|
|
|
const input = document.createElement('input')
|
|
|
input.type = 'file'
|
|
|
input.accept = 'image/*'
|
|
|
input.onchange = async () => {
|
|
|
const file = input.files?.[0]
|
|
|
if (!file) 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)
|
|
|
Message.success('图片已上传并插入')
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '图片上传失败')
|
|
|
}
|
|
|
}
|
|
|
input.click()
|
|
|
}
|
|
|
|
|
|
function quillVideoHandler(this: { quill: any }) {
|
|
|
const quill = this.quill
|
|
|
const input = document.createElement('input')
|
|
|
input.type = 'file'
|
|
|
input.accept = 'video/*'
|
|
|
input.onchange = async () => {
|
|
|
const file = input.files?.[0]
|
|
|
if (!file) 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, 'video', url, 'user')
|
|
|
quill.setSelection(index + 1, 0)
|
|
|
Message.success('视频已上传并插入')
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '视频上传失败')
|
|
|
}
|
|
|
}
|
|
|
input.click()
|
|
|
}
|
|
|
|
|
|
const quillToolbarModules = {
|
|
|
toolbar: {
|
|
|
container: [
|
|
|
[{ header: [1, 2, 3, false] }],
|
|
|
['bold', 'italic', 'underline', 'strike'],
|
|
|
[{ color: [] }, { background: [] }],
|
|
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
|
|
[{ align: [] }],
|
|
|
['link', 'image', 'video'],
|
|
|
['clean'],
|
|
|
],
|
|
|
handlers: {
|
|
|
image: quillImageHandler,
|
|
|
video: quillVideoHandler,
|
|
|
},
|
|
|
},
|
|
|
}
|
|
|
|
|
|
const detailEditorOptions = {
|
|
|
modules: quillToolbarModules,
|
|
|
placeholder: '请输入场馆详情内容',
|
|
|
}
|
|
|
|
|
|
const reservationEditorOptions = {
|
|
|
modules: quillToolbarModules,
|
|
|
placeholder: '',
|
|
|
}
|
|
|
|
|
|
const form = reactive({
|
|
|
name: '',
|
|
|
venue_types: [] as string[],
|
|
|
unit_name: '',
|
|
|
district: '',
|
|
|
ticket_type: '',
|
|
|
appointment_type: '',
|
|
|
booking_mode: '',
|
|
|
open_mode: '',
|
|
|
open_time: '',
|
|
|
reservation_notice: '',
|
|
|
ticket_content: '',
|
|
|
booking_method: '',
|
|
|
visit_form: '',
|
|
|
consultation_hours: '',
|
|
|
booking_qr_media: [] as Array<{ type: 'image'; url: string }>,
|
|
|
address: '',
|
|
|
contact_phone: '',
|
|
|
lat: undefined as number | undefined,
|
|
|
lng: undefined as number | undefined,
|
|
|
cover_image: '',
|
|
|
gallery_media: [] as Array<{ type: 'image' | 'video'; url: string }>,
|
|
|
detail_html: '',
|
|
|
sort: 0,
|
|
|
is_active: true,
|
|
|
is_included_in_stats: false,
|
|
|
})
|
|
|
|
|
|
function isSuperAdmin() {
|
|
|
return currentUser.value?.full_admin_access === true
|
|
|
}
|
|
|
|
|
|
async function approveVenue(row: Venue) {
|
|
|
try {
|
|
|
await http.post(`/venues/${row.id}/audit/approve`)
|
|
|
Message.success('已通过审核')
|
|
|
await loadRows()
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '操作失败')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function openRejectVenue(row: Venue) {
|
|
|
rejectVenueId.value = row.id
|
|
|
rejectVenueRemark.value = ''
|
|
|
rejectVenueVisible.value = true
|
|
|
}
|
|
|
|
|
|
async function submitRejectVenue(): Promise<boolean> {
|
|
|
if (!rejectVenueId.value) return false
|
|
|
try {
|
|
|
await http.post(`/venues/${rejectVenueId.value}/audit/reject`, { remark: rejectVenueRemark.value || undefined })
|
|
|
Message.success('已退回')
|
|
|
rejectVenueVisible.value = false
|
|
|
await loadRows()
|
|
|
return true
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '操作失败')
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function removeVenue(row: Venue) {
|
|
|
try {
|
|
|
await http.delete(`/venues/${row.id}`)
|
|
|
Message.success('删除成功')
|
|
|
await loadRows()
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '删除失败')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function listCellOneLine(s?: string | null) {
|
|
|
const t = (s || '').trim()
|
|
|
if (!t) return '-'
|
|
|
const plain = t.replace(/<[^>]+>/g, ' ')
|
|
|
return plain.replace(/\s+/g, ' ')
|
|
|
}
|
|
|
|
|
|
function optionLabel(list: DictItem[], value?: string | null) {
|
|
|
if (!value) return '-'
|
|
|
return list.find((item) => item.item_value === value)?.item_label || value
|
|
|
}
|
|
|
|
|
|
function optionColor(list: DictItem[], value?: string | null, fallback = 'arcoblue') {
|
|
|
if (!value) return fallback
|
|
|
return list.find((item) => item.item_value === value)?.item_remark || fallback
|
|
|
}
|
|
|
|
|
|
function venueTypeValues(record: Venue): string[] {
|
|
|
const raw = record.venue_types
|
|
|
if (Array.isArray(raw) && raw.length) return raw.map((v) => String(v))
|
|
|
return record.venue_type ? [String(record.venue_type)] : []
|
|
|
}
|
|
|
|
|
|
function mapJsKey() {
|
|
|
return (import.meta.env.VITE_TENCENT_MAP_KEY as string) || ''
|
|
|
}
|
|
|
|
|
|
function normalizeMediaUrl(rawUrl?: string, rawPath?: string) {
|
|
|
const urlText = String(rawUrl || '').trim()
|
|
|
if (urlText) {
|
|
|
return resolvePublicMediaUrl(urlText)
|
|
|
}
|
|
|
const pathText = String(rawPath || '').trim()
|
|
|
if (!pathText) return ''
|
|
|
return resolvePublicMediaUrl(pathText)
|
|
|
}
|
|
|
|
|
|
async function uploadFile(file: File) {
|
|
|
const fd = new FormData()
|
|
|
fd.append('file', file)
|
|
|
const response = await http.post('/upload', fd)
|
|
|
const data = response?.data || {}
|
|
|
return normalizeMediaUrl(data.url, data.path)
|
|
|
}
|
|
|
|
|
|
function resetEditors() {
|
|
|
editorRenderKey.value += 1
|
|
|
}
|
|
|
|
|
|
function openMediaPreview(type: 'image' | 'video', url: string) {
|
|
|
if (!url) return
|
|
|
mediaPreviewType.value = type
|
|
|
mediaPreviewUrl.value = resolvePublicMediaUrl(url)
|
|
|
mediaPreviewVisible.value = true
|
|
|
}
|
|
|
|
|
|
function pickUploadFile(payload: any): File | null {
|
|
|
const visited = new Set<any>()
|
|
|
const queue: any[] = [payload]
|
|
|
|
|
|
while (queue.length) {
|
|
|
const cur = queue.shift()
|
|
|
if (!cur || visited.has(cur)) continue
|
|
|
visited.add(cur)
|
|
|
|
|
|
if (cur instanceof File) return cur
|
|
|
if (cur?.target?.files?.[0] instanceof File) return cur.target.files[0]
|
|
|
|
|
|
if (Array.isArray(cur)) {
|
|
|
for (const item of cur) queue.push(item)
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
if (typeof cur === 'object') {
|
|
|
const maybeKeys = ['file', 'raw', 'originFile', 'originFileObj', 'fileItem', 'item', 'data']
|
|
|
for (const key of maybeKeys) {
|
|
|
if (cur[key]) queue.push(cur[key])
|
|
|
}
|
|
|
for (const value of Object.values(cur)) {
|
|
|
if (value && (typeof value === 'object' || Array.isArray(value))) {
|
|
|
queue.push(value)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
async function loadRows() {
|
|
|
loading.value = true
|
|
|
try {
|
|
|
const { data } = await http.get('/venues', {
|
|
|
params: {
|
|
|
keyword: filters.keyword || undefined,
|
|
|
district: filters.district || undefined,
|
|
|
venue_type: filters.venue_type || undefined,
|
|
|
ticket_type: filters.ticket_type || undefined,
|
|
|
booking_mode: filters.booking_mode || undefined,
|
|
|
open_mode: filters.open_mode || undefined,
|
|
|
appointment_type: filters.appointment_type || undefined,
|
|
|
is_active: filters.is_active || undefined,
|
|
|
is_included_in_stats: filters.is_included_in_stats || undefined,
|
|
|
},
|
|
|
})
|
|
|
rows.value = data
|
|
|
pagination.total = data.length
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '加载场馆失败')
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadDistricts() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'district', active_only: 1 } })
|
|
|
districtOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadVenueTypes() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'venue_type', active_only: 1 } })
|
|
|
venueTypeOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadAppointmentTypes() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'venue_appointment_type', active_only: 1 } })
|
|
|
appointmentTypeOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadOpenModes() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'venue_open_mode', active_only: 1 } })
|
|
|
openModeOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadBookingModes() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'venue_booking_mode', active_only: 1 } })
|
|
|
bookingModeOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadTicketTypes() {
|
|
|
const { data } = await http.get('/dict-items', { params: { dict_type: 'ticket_type', active_only: 1 } })
|
|
|
ticketTypeOptions.value = data
|
|
|
}
|
|
|
|
|
|
async function loadMe() {
|
|
|
const { data } = await http.get('/me')
|
|
|
currentUser.value = data
|
|
|
}
|
|
|
|
|
|
function openCreate() {
|
|
|
isCreate.value = true
|
|
|
editId.value = null
|
|
|
clearFormErrors()
|
|
|
form.name = ''
|
|
|
form.venue_types = []
|
|
|
form.unit_name = ''
|
|
|
form.district = ''
|
|
|
form.ticket_type = ''
|
|
|
form.booking_mode = ''
|
|
|
form.open_mode = ''
|
|
|
form.open_time = ''
|
|
|
form.reservation_notice = ''
|
|
|
form.ticket_content = ''
|
|
|
form.booking_method = ''
|
|
|
form.visit_form = ''
|
|
|
form.consultation_hours = ''
|
|
|
form.booking_qr_media = []
|
|
|
form.address = ''
|
|
|
form.contact_phone = ''
|
|
|
form.lat = undefined
|
|
|
form.lng = undefined
|
|
|
form.cover_image = ''
|
|
|
form.gallery_media = []
|
|
|
form.detail_html = ''
|
|
|
form.sort = 0
|
|
|
form.is_active = true
|
|
|
form.is_included_in_stats = false
|
|
|
resetEditors()
|
|
|
visible.value = true
|
|
|
}
|
|
|
|
|
|
function openEdit(row: Venue) {
|
|
|
isCreate.value = false
|
|
|
editId.value = row.id
|
|
|
clearFormErrors()
|
|
|
form.name = row.name
|
|
|
form.venue_types =
|
|
|
Array.isArray(row.venue_types) && row.venue_types.length
|
|
|
? [...row.venue_types]
|
|
|
: row.venue_type
|
|
|
? [row.venue_type]
|
|
|
: []
|
|
|
form.unit_name = row.unit_name ?? ''
|
|
|
form.district = row.district ?? ''
|
|
|
form.ticket_type = row.ticket_type ?? ''
|
|
|
form.appointment_type = row.appointment_type ?? ''
|
|
|
form.booking_mode = row.booking_mode ?? ''
|
|
|
form.open_mode = row.open_mode ?? ''
|
|
|
form.open_time = row.open_time ?? ''
|
|
|
form.reservation_notice = row.reservation_notice ?? ''
|
|
|
form.ticket_content = row.ticket_content ?? ''
|
|
|
form.booking_method = row.booking_method ?? ''
|
|
|
form.visit_form = row.visit_form ?? ''
|
|
|
form.consultation_hours = row.consultation_hours ?? ''
|
|
|
{
|
|
|
const raw = (row as { booking_qr_media?: Array<{ type?: string; url?: string }> }).booking_qr_media
|
|
|
if (Array.isArray(raw) && raw.length) {
|
|
|
form.booking_qr_media = raw
|
|
|
.filter((x) => x && x.url && (x as { type?: string }).type === 'image')
|
|
|
.map((x) => ({ type: 'image' as const, url: String(x.url) }))
|
|
|
} else {
|
|
|
form.booking_qr_media = []
|
|
|
}
|
|
|
}
|
|
|
form.address = row.address ?? ''
|
|
|
form.contact_phone = row.contact_phone ?? ''
|
|
|
form.lat = parseCoord(row.lat)
|
|
|
form.lng = parseCoord(row.lng)
|
|
|
form.cover_image = row.cover_image ?? ''
|
|
|
form.gallery_media = Array.isArray(row.gallery_media) ? [...row.gallery_media] : []
|
|
|
form.detail_html = row.detail_html ?? ''
|
|
|
const sortNum = row.sort
|
|
|
form.sort =
|
|
|
typeof sortNum === 'number' && Number.isFinite(sortNum)
|
|
|
? sortNum
|
|
|
: parseInt(String(sortNum ?? '0'), 10) || 0
|
|
|
form.is_active = row.is_active
|
|
|
form.is_included_in_stats = row.is_included_in_stats ?? false
|
|
|
resetEditors()
|
|
|
visible.value = true
|
|
|
}
|
|
|
|
|
|
async function onCoverSelect(fileItem: any) {
|
|
|
try {
|
|
|
const file = pickUploadFile(fileItem)
|
|
|
if (!file) {
|
|
|
Message.warning('未识别到上传文件')
|
|
|
return false
|
|
|
}
|
|
|
form.cover_image = await uploadFile(file)
|
|
|
Message.success('封面上传成功')
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '封面上传失败')
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
async function onGallerySelect(fileItem: any) {
|
|
|
try {
|
|
|
const file = pickUploadFile(fileItem)
|
|
|
if (!file) {
|
|
|
Message.warning('未识别到上传文件')
|
|
|
return false
|
|
|
}
|
|
|
const url = await uploadFile(file)
|
|
|
if (!url) {
|
|
|
Message.error('上传成功但未返回可用地址')
|
|
|
return false
|
|
|
}
|
|
|
const isVideo = file.type.startsWith('video/')
|
|
|
form.gallery_media.push({ type: isVideo ? 'video' : 'image', url })
|
|
|
Message.success('轮播资源上传成功')
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '轮播资源上传失败')
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
function onCoverChange(...args: any[]) {
|
|
|
void onCoverSelect(args)
|
|
|
}
|
|
|
|
|
|
function onGalleryChange(...args: any[]) {
|
|
|
void onGallerySelect(args)
|
|
|
}
|
|
|
|
|
|
function removeGallery(index: number) {
|
|
|
form.gallery_media.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
async function onBookingQrSelect(file: File) {
|
|
|
try {
|
|
|
if (!file) {
|
|
|
Message.warning('未识别到上传文件')
|
|
|
return false
|
|
|
}
|
|
|
if (!file.type.startsWith('image/')) {
|
|
|
Message.warning('仅支持图片')
|
|
|
return false
|
|
|
}
|
|
|
const url = await uploadFile(file)
|
|
|
if (!url) {
|
|
|
Message.error('上传成功但未返回可用地址')
|
|
|
return false
|
|
|
}
|
|
|
form.booking_qr_media.push({ type: 'image', url })
|
|
|
Message.success('已上传')
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '上传失败')
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
function removeBookingQr(index: number) {
|
|
|
form.booking_qr_media.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
function removeCover() {
|
|
|
form.cover_image = ''
|
|
|
}
|
|
|
|
|
|
function onImageLoadError(ev?: Event) {
|
|
|
const el = ev?.target as HTMLImageElement | undefined
|
|
|
const src = (el?.getAttribute?.('src') || el?.src || '').trim()
|
|
|
// 空 src 或占位仍会触发 error,勿误报 storage
|
|
|
if (!src || src === 'about:blank') return
|
|
|
Message.error('图片地址无法访问,请检查后端 storage 访问配置')
|
|
|
}
|
|
|
|
|
|
function onSearch() {
|
|
|
pagination.current = 1
|
|
|
void loadRows()
|
|
|
}
|
|
|
|
|
|
function onPageChange(p: number) {
|
|
|
pagination.current = p
|
|
|
}
|
|
|
|
|
|
async function ensureMapSdkLoaded() {
|
|
|
const w = window as any
|
|
|
if (w.TMap) return
|
|
|
const key = mapJsKey()
|
|
|
if (!key) {
|
|
|
throw new Error('请先配置 VITE_TENCENT_MAP_KEY')
|
|
|
}
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
const script = document.createElement('script')
|
|
|
script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${key}`
|
|
|
script.async = true
|
|
|
script.onload = () => resolve()
|
|
|
script.onerror = () => reject(new Error('腾讯地图SDK加载失败'))
|
|
|
document.head.appendChild(script)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function renderMarker(lat: number, lng: number) {
|
|
|
const TMap = (window as any).TMap
|
|
|
if (!mapInstance) return
|
|
|
if (markerLayer) markerLayer.setMap(null)
|
|
|
markerLayer = new TMap.MultiMarker({
|
|
|
map: mapInstance,
|
|
|
styles: {
|
|
|
marker: new TMap.MarkerStyle({ width: 24, height: 35 }),
|
|
|
},
|
|
|
geometries: [{ id: 'picked', styleId: 'marker', position: new TMap.LatLng(lat, lng) }],
|
|
|
})
|
|
|
mapInstance.setCenter(new TMap.LatLng(lat, lng))
|
|
|
}
|
|
|
|
|
|
function refreshMapViewport(lat: number, lng: number) {
|
|
|
const TMap = (window as any).TMap
|
|
|
if (!mapInstance || !TMap) return
|
|
|
const center = new TMap.LatLng(lat, lng)
|
|
|
// 弹窗场景下首帧可能未完成布局,强制刷新两次可避免灰底无底图
|
|
|
mapInstance.resize?.()
|
|
|
mapInstance.setCenter(center)
|
|
|
mapInstance.setZoom(13)
|
|
|
setTimeout(() => {
|
|
|
mapInstance.resize?.()
|
|
|
mapInstance.setCenter(center)
|
|
|
}, 120)
|
|
|
}
|
|
|
|
|
|
async function reverseGeocode(lat: number, lng: number) {
|
|
|
const { data } = await http.get('/map/reverse-geocode', { params: { lat, lng } })
|
|
|
pickedPoint.value = { lat, lng, address: data.address || '' }
|
|
|
if (data.district) {
|
|
|
const exists = districtOptions.value.some((d) => d.item_value === data.district)
|
|
|
if (exists) form.district = data.district
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function initMapPicker() {
|
|
|
await ensureMapSdkLoaded()
|
|
|
const TMap = (window as any).TMap
|
|
|
const lat = typeof form.lat === 'number' ? form.lat : DEFAULT_CENTER.lat
|
|
|
const lng = typeof form.lng === 'number' ? form.lng : DEFAULT_CENTER.lng
|
|
|
const center = new TMap.LatLng(lat, lng)
|
|
|
if (!mapInstance) {
|
|
|
mapInstance = new TMap.Map(mapContainerRef.value, { center, zoom: 13 })
|
|
|
mapInstance.on('click', async (evt: any) => {
|
|
|
const lat = Number(evt.latLng.getLat().toFixed(6))
|
|
|
const lng = Number(evt.latLng.getLng().toFixed(6))
|
|
|
renderMarker(lat, lng)
|
|
|
try {
|
|
|
await reverseGeocode(lat, lng)
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '逆地理编码失败')
|
|
|
}
|
|
|
})
|
|
|
} else {
|
|
|
mapInstance.setCenter(center)
|
|
|
}
|
|
|
refreshMapViewport(lat, lng)
|
|
|
if (typeof form.lat !== 'undefined' && typeof form.lng !== 'undefined') {
|
|
|
renderMarker(form.lat, form.lng)
|
|
|
pickedPoint.value = { lat: form.lat, lng: form.lng, address: form.address || '' }
|
|
|
} else {
|
|
|
if (markerLayer) {
|
|
|
markerLayer.setMap(null)
|
|
|
markerLayer = null
|
|
|
}
|
|
|
pickedPoint.value = null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function openMapPicker() {
|
|
|
if (!isCreate.value) return
|
|
|
mapVisible.value = true
|
|
|
mapKeyword.value = ''
|
|
|
mapResults.value = []
|
|
|
mapLoading.value = true
|
|
|
try {
|
|
|
await nextTick()
|
|
|
setTimeout(async () => {
|
|
|
try {
|
|
|
await initMapPicker()
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.message ?? '地图初始化失败')
|
|
|
} finally {
|
|
|
mapLoading.value = false
|
|
|
}
|
|
|
}, 250)
|
|
|
} catch {
|
|
|
mapLoading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function searchMapKeyword() {
|
|
|
if (!mapKeyword.value.trim()) {
|
|
|
mapResults.value = []
|
|
|
return
|
|
|
}
|
|
|
mapLoading.value = true
|
|
|
try {
|
|
|
const { data } = await http.get('/map/search', { params: { keyword: mapKeyword.value, region: '苏州' } })
|
|
|
mapResults.value = data
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '地图搜索失败')
|
|
|
} finally {
|
|
|
mapLoading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function pickSearchResult(item: { title: string; address: string; lat: number; lng: number }) {
|
|
|
renderMarker(item.lat, item.lng)
|
|
|
pickedPoint.value = { lat: item.lat, lng: item.lng, address: item.address || '' }
|
|
|
try {
|
|
|
await reverseGeocode(item.lat, item.lng)
|
|
|
} catch {
|
|
|
// ignore
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function confirmMapPick() {
|
|
|
if (!pickedPoint.value) {
|
|
|
Message.warning('请先点击地图或选择搜索结果')
|
|
|
return false
|
|
|
}
|
|
|
form.lat = pickedPoint.value.lat
|
|
|
form.lng = pickedPoint.value.lng
|
|
|
form.address = pickedPoint.value.address || form.address
|
|
|
mapVisible.value = false
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
function validateForm(): boolean {
|
|
|
clearFormErrors()
|
|
|
let isValid = true
|
|
|
|
|
|
// 基础信息验证
|
|
|
if (!form.name.trim()) {
|
|
|
formErrors.name = '场馆名称为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
if (!form.district.trim()) {
|
|
|
formErrors.district = '行政区为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
if (!form.address.trim()) {
|
|
|
formErrors.address = '场馆地址为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
if (typeof form.lat !== 'number' || typeof form.lng !== 'number') {
|
|
|
formErrors.lat = '经纬度为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 主题验证
|
|
|
if (!form.venue_types.length) {
|
|
|
formErrors.venue_types = '主题为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 所属单位验证
|
|
|
if (!form.unit_name.trim()) {
|
|
|
formErrors.unit_name = '所属单位为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 门票类型验证
|
|
|
if (!form.ticket_type) {
|
|
|
formErrors.ticket_type = '门票类型为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 开放模式验证
|
|
|
if (!form.open_mode) {
|
|
|
formErrors.open_mode = '开放模式为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 参观形式验证
|
|
|
if (!form.visit_form.trim()) {
|
|
|
formErrors.visit_form = '参观形式为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 开放时间验证
|
|
|
if (!form.open_time.trim()) {
|
|
|
formErrors.open_time = '开放时间为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 咨询时间验证
|
|
|
if (!form.consultation_hours.trim()) {
|
|
|
formErrors.consultation_hours = '咨询时间为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 咨询电话验证
|
|
|
if (!form.contact_phone.trim()) {
|
|
|
formErrors.contact_phone = '咨询电话为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 预约方式验证
|
|
|
if (!form.booking_method.trim()) {
|
|
|
formErrors.booking_method = '预约方式为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 门票说明验证
|
|
|
if (!form.ticket_content.trim()) {
|
|
|
formErrors.ticket_content = '门票说明为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 科普场馆主图验证
|
|
|
if (!form.cover_image) {
|
|
|
formErrors.cover_image = '科普场馆主图为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 科普场馆展示图片验证
|
|
|
if (!form.gallery_media.length) {
|
|
|
formErrors.gallery_media = '科普场馆展示图片为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
// 场馆简介验证
|
|
|
if (!form.detail_html || !form.detail_html.trim()) {
|
|
|
formErrors.detail_html = '场馆简介为必填项'
|
|
|
isValid = false
|
|
|
}
|
|
|
|
|
|
return isValid
|
|
|
}
|
|
|
|
|
|
async function submit(): Promise<boolean> {
|
|
|
saving.value = true
|
|
|
try {
|
|
|
// 表单验证
|
|
|
if (!validateForm()) {
|
|
|
Message.warning('请填写所有必填项')
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
const payload = {
|
|
|
...form,
|
|
|
booking_qr_media: form.booking_qr_media || [],
|
|
|
sort: isSuperAdmin() ? form.sort : undefined,
|
|
|
}
|
|
|
if (isCreate.value) {
|
|
|
await http.post('/venues', payload)
|
|
|
Message.success('创建场馆成功')
|
|
|
} else if (editId.value) {
|
|
|
await http.put(`/venues/${editId.value}`, payload)
|
|
|
Message.success('更新场馆成功')
|
|
|
}
|
|
|
await loadRows()
|
|
|
return true
|
|
|
} catch (error: any) {
|
|
|
Message.error(error?.response?.data?.message ?? '保存失败')
|
|
|
return false
|
|
|
} finally {
|
|
|
saving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function onBeforeOk() {
|
|
|
return await submit()
|
|
|
}
|
|
|
|
|
|
onMounted(async () => {
|
|
|
await loadMe()
|
|
|
await Promise.all([
|
|
|
loadRows(),
|
|
|
loadDistricts(),
|
|
|
loadVenueTypes(),
|
|
|
loadAppointmentTypes(),
|
|
|
loadBookingModes(),
|
|
|
loadOpenModes(),
|
|
|
loadTicketTypes(),
|
|
|
])
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<a-card title="场馆管理 / 场馆列表">
|
|
|
<template #extra>
|
|
|
<a-space wrap>
|
|
|
<a-input v-model="filters.keyword" placeholder="搜索名称/地址/开放时间/预约须知等" style="width: 260px" />
|
|
|
<a-select v-model="filters.venue_type" allow-clear placeholder="筛选主题" style="width: 180px">
|
|
|
<a-option v-for="item in venueTypeOptions" :key="item.id" :value="item.item_value">{{ item.item_label }}</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.district" allow-clear placeholder="筛选行政区" style="width: 180px">
|
|
|
<a-option v-for="item in districtOptions" :key="item.id" :value="item.item_value">{{ item.item_label }}</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.ticket_type" allow-clear placeholder="筛选门票类型" style="width: 180px">
|
|
|
<a-option v-for="item in ticketTypeOptions" :key="item.id" :value="item.item_value">{{ item.item_label }}</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.booking_mode" allow-clear placeholder="筛选预约模式" style="width: 180px">
|
|
|
<a-option v-for="item in bookingModeOptions" :key="item.id" :value="item.item_value">{{ item.item_label }}</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.open_mode" allow-clear placeholder="筛选开放模式" style="width: 180px">
|
|
|
<a-option v-for="item in openModeOptions" :key="item.id" :value="item.item_value">{{ item.item_label }}</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.is_included_in_stats" allow-clear placeholder="纳入市科协人数统计系统" style="width: 180px">
|
|
|
<a-option value="1">纳入统计</a-option>
|
|
|
<a-option value="0">不纳入</a-option>
|
|
|
</a-select>
|
|
|
<a-select v-model="filters.is_active" allow-clear placeholder="上架状态" style="width: 130px">
|
|
|
<a-option value="1">上架</a-option>
|
|
|
<a-option value="0">下架</a-option>
|
|
|
</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-space>
|
|
|
</template>
|
|
|
|
|
|
<a-table
|
|
|
class="list-data-table"
|
|
|
:scroll="{ x: VENUE_LIST_SCROLL_X }"
|
|
|
:data="rows"
|
|
|
:loading="loading"
|
|
|
row-key="id"
|
|
|
:pagination="{ current: pagination.current, pageSize: pagination.pageSize, total: pagination.total, showTotal: true, showJumper: true }"
|
|
|
@page-change="onPageChange"
|
|
|
>
|
|
|
<template #columns>
|
|
|
<a-table-column title="" :width="50" :ellipsis="true" :tooltip="true">
|
|
|
<template #cell="{ rowIndex }">{{
|
|
|
listTableRowIndex(rowIndex, pagination.current, pagination.pageSize)
|
|
|
}}</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="场馆名称" data-index="name" :width="220" :min-width="160" :ellipsis="true" :tooltip="true" />
|
|
|
<a-table-column title="主题" :width="130">
|
|
|
<template #cell="{ record }">
|
|
|
<a-space v-if="venueTypeValues(record).length" wrap :size="4">
|
|
|
<a-tag
|
|
|
v-for="(v, i) in venueTypeValues(record)"
|
|
|
:key="record.id + '-vt-' + i"
|
|
|
:color="optionColor(venueTypeOptions, v, 'arcoblue')"
|
|
|
>
|
|
|
{{ optionLabel(venueTypeOptions, v) }}
|
|
|
</a-tag>
|
|
|
</a-space>
|
|
|
<span v-else>-</span>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="行政区" data-index="district" :width="120" :ellipsis="true" :tooltip="true" />
|
|
|
<a-table-column title="所属单位" data-index="unit_name" :width="200" :min-width="140" :ellipsis="true" :tooltip="true" />
|
|
|
<a-table-column title="门票类型" :width="120">
|
|
|
<template #cell="{ record }">
|
|
|
<a-tag :color="optionColor(ticketTypeOptions, record.ticket_type, 'green')">
|
|
|
{{ optionLabel(ticketTypeOptions, record.ticket_type) }}
|
|
|
</a-tag>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="预约模式" :width="180">
|
|
|
<template #cell="{ record }">
|
|
|
<span>{{ optionLabel(bookingModeOptions, record.booking_mode) }}</span>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="开放模式" :width="150">
|
|
|
<template #cell="{ record }">
|
|
|
<span>{{ optionLabel(openModeOptions, record.open_mode) }}</span>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="开放时间" :width="220" :min-width="160" :ellipsis="true" :tooltip="true">
|
|
|
<template #cell="{ record }">{{ listCellOneLine(record.open_time) }}</template>
|
|
|
</a-table-column>
|
|
|
<a-table-column title="地址" data-index="address" :width="280" :min-width="200" :ellipsis="true" :tooltip="true" />
|
|
|
<a-table-column title="排序" data-index="sort" :width="90" :ellipsis="true" :tooltip="true" />
|
|
|
<a-table-column title="上架状态" :width="100">
|
|
|
<template #cell="{ record }">
|
|
|
<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">
|
|
|
<template #cell="{ record }">
|
|
|
<a-space wrap justify="start">
|
|
|
<a-button type="text" @click="openEdit(record)">编辑</a-button>
|
|
|
<template v-if="isSuperAdmin() && (record.audit_status === 'pending' || record.audit_status === 'rejected')">
|
|
|
<a-button type="text" status="success" @click="approveVenue(record)">通过</a-button>
|
|
|
<a-button type="text" status="danger" @click="openRejectVenue(record)">退回</a-button>
|
|
|
</template>
|
|
|
<a-popconfirm
|
|
|
v-if="isSuperAdmin()"
|
|
|
content="删除后该场馆关联的活动、预约等数据将一并删除,且不可恢复,确认删除?"
|
|
|
@ok="removeVenue(record)"
|
|
|
>
|
|
|
<a-button type="text" status="danger">删除</a-button>
|
|
|
</a-popconfirm>
|
|
|
</a-space>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
</template>
|
|
|
</a-table>
|
|
|
</a-card>
|
|
|
|
|
|
<a-modal v-model:visible="rejectVenueVisible" title="退回场馆" :on-before-ok="submitRejectVenue">
|
|
|
<a-textarea v-model="rejectVenueRemark" placeholder="退回说明(选填)" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
|
|
</a-modal>
|
|
|
|
|
|
<a-modal
|
|
|
v-model:visible="visible"
|
|
|
:title="isCreate ? '新增场馆' : '编辑场馆'"
|
|
|
width="70%"
|
|
|
:body-style="modalBodyStyle"
|
|
|
:confirm-loading="saving"
|
|
|
:on-before-ok="onBeforeOk"
|
|
|
>
|
|
|
<a-form :model="form" layout="vertical" class="admin-modal-form">
|
|
|
<a-form-item label="场馆名称" required :help="formErrors.name">
|
|
|
<a-input v-model="form.name" />
|
|
|
<template v-if="formErrors.name" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.name }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="主题(可多选)" required :help="formErrors.venue_types">
|
|
|
<a-select v-model="form.venue_types" multiple placeholder="请选择主题">
|
|
|
<a-option v-for="item in venueTypeOptions" :key="item.id" :value="item.item_value">
|
|
|
{{ item.item_label }}
|
|
|
</a-option>
|
|
|
</a-select>
|
|
|
<template v-if="formErrors.venue_types" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.venue_types }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="行政区" required :help="formErrors.district">
|
|
|
<a-select v-model="form.district" allow-clear placeholder="请选择行政区">
|
|
|
<a-option v-for="item in districtOptions" :key="item.id" :value="item.item_value">
|
|
|
{{ item.item_label }}
|
|
|
</a-option>
|
|
|
</a-select>
|
|
|
<template v-if="formErrors.district" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.district }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="所属单位" required :help="formErrors.unit_name">
|
|
|
<a-input v-model="form.unit_name" placeholder="所属单位名称" />
|
|
|
<template v-if="formErrors.unit_name" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.unit_name }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="门票类型" required :help="formErrors.ticket_type">
|
|
|
<a-select v-model="form.ticket_type" placeholder="请选择门票类型">
|
|
|
<a-option v-for="item in ticketTypeOptions" :key="item.id" :value="item.item_value">
|
|
|
{{ item.item_label }}
|
|
|
</a-option>
|
|
|
</a-select>
|
|
|
<template v-if="formErrors.ticket_type" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.ticket_type }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="预约模式" :help="formErrors.booking_mode">
|
|
|
<a-select v-model="form.booking_mode" allow-clear placeholder="请选择预约模式">
|
|
|
<a-option v-for="item in bookingModeOptions" :key="item.id" :value="item.item_value">
|
|
|
{{ item.item_label }}
|
|
|
</a-option>
|
|
|
</a-select>
|
|
|
<template v-if="formErrors.booking_mode" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.booking_mode }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="开放模式" required :help="formErrors.open_mode">
|
|
|
<a-select v-model="form.open_mode" placeholder="请选择开放模式">
|
|
|
<a-option v-for="item in openModeOptions" :key="item.id" :value="item.item_value">
|
|
|
{{ item.item_label }}
|
|
|
</a-option>
|
|
|
</a-select>
|
|
|
<template v-if="formErrors.open_mode" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.open_mode }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="参观形式" required :help="formErrors.visit_form">
|
|
|
<a-input v-model="form.visit_form" placeholder="参观形式说明" />
|
|
|
<template v-if="formErrors.visit_form" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.visit_form }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="开放时间" required :help="formErrors.open_time">
|
|
|
<a-input v-model="form.open_time" placeholder="如:周一至周五 09:00-17:00,周末 09:00-18:00" />
|
|
|
<template v-if="formErrors.open_time" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.open_time }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="咨询时间" required :help="formErrors.consultation_hours">
|
|
|
<a-input v-model="form.consultation_hours" placeholder="咨询时间说明" />
|
|
|
<template v-if="formErrors.consultation_hours" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.consultation_hours }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="咨询电话" required :help="formErrors.contact_phone">
|
|
|
<a-input v-model="form.contact_phone" placeholder="前台可点击拨打" />
|
|
|
<template v-if="formErrors.contact_phone" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.contact_phone }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="排序">
|
|
|
<a-input-number v-model="form.sort" :min="0" :disabled="!isSuperAdmin()" />
|
|
|
</a-form-item>
|
|
|
<a-row :gutter="16">
|
|
|
<a-col :span="12">
|
|
|
<a-form-item label="上架状态">
|
|
|
<a-switch v-model="form.is_active" />
|
|
|
<span style="margin-left: 8px; color: var(--color-text-3)">{{ form.is_active ? '上架' : '下架' }}</span>
|
|
|
</a-form-item>
|
|
|
</a-col>
|
|
|
<a-col :span="12">
|
|
|
<a-form-item label="纳入市科协人数统计系统">
|
|
|
<a-switch v-model="form.is_included_in_stats" />
|
|
|
<span style="margin-left: 8px; color: var(--color-text-3)">{{ form.is_included_in_stats ? '是' : '否' }}</span>
|
|
|
</a-form-item>
|
|
|
</a-col>
|
|
|
</a-row>
|
|
|
<a-form-item label="预约方式与预约二维码" required class="admin-modal-form__full">
|
|
|
<div style="display: flex; gap: 16px; width: 100%">
|
|
|
<div style="flex: 1 1 50%; min-width: 0">
|
|
|
<div class="venue-form-split-label">预约方式 <span style="color: #f53f3f">*</span></div>
|
|
|
<a-input v-model="form.booking_method" placeholder="单行文字说明" style="width: 100%" />
|
|
|
</div>
|
|
|
<div style="flex: 1 1 50%; min-width: 0">
|
|
|
<div class="venue-form-split-label">预约二维码</div>
|
|
|
<a-upload
|
|
|
:auto-upload="false"
|
|
|
:show-file-list="false"
|
|
|
:limit="20"
|
|
|
multiple
|
|
|
accept="image/*"
|
|
|
:on-before-upload="onBookingQrSelect"
|
|
|
>
|
|
|
<template #upload-button><a-button type="primary" size="small">上传图片</a-button></template>
|
|
|
</a-upload>
|
|
|
<a-typography-text type="secondary" style="margin-top: 8px; display: block; font-size: 12px">推荐 1200×600</a-typography-text>
|
|
|
<div v-if="form.booking_qr_media.length" class="venue-gallery-grid" style="margin-top: 8px">
|
|
|
<div v-for="(m, i) in form.booking_qr_media" :key="`booking-qr-${i}`" class="venue-gallery-item">
|
|
|
<img
|
|
|
:src="resolvePublicMediaUrl(m.url)"
|
|
|
class="venue-gallery-thumb"
|
|
|
@error="onImageLoadError"
|
|
|
@click="openMediaPreview('image', m.url)"
|
|
|
/>
|
|
|
<a-button size="mini" status="danger" @click="removeBookingQr(i)">删除</a-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<template #help>
|
|
|
<span v-if="formErrors.booking_method" style="color: #f53f3f;">{{ formErrors.booking_method }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="门票说明" required class="admin-modal-form__full" :help="formErrors.ticket_content">
|
|
|
<a-textarea v-model="form.ticket_content" :auto-size="{ minRows: 3, maxRows: 12 }" placeholder="门票说明" />
|
|
|
<template v-if="formErrors.ticket_content" #help>
|
|
|
<span style="color: #f53f3f;">{{ formErrors.ticket_content }}</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="场馆地址与经纬度" required class="admin-modal-form__full">
|
|
|
<div class="venue-address-coord-row">
|
|
|
<a-input v-model="form.address" class="venue-address-coord-row__address" placeholder="场馆地址" allow-clear />
|
|
|
<a-input-number
|
|
|
v-model="form.lng"
|
|
|
class="venue-address-coord-row__lng"
|
|
|
:precision="7"
|
|
|
placeholder="经度"
|
|
|
hide-button
|
|
|
disabled
|
|
|
/>
|
|
|
<a-input-number
|
|
|
v-model="form.lat"
|
|
|
class="venue-address-coord-row__lat"
|
|
|
:precision="7"
|
|
|
placeholder="纬度"
|
|
|
hide-button
|
|
|
disabled
|
|
|
/>
|
|
|
<a-button v-if="isCreate" type="primary" class="venue-address-coord-row__map" @click="openMapPicker">地图选点</a-button>
|
|
|
</div>
|
|
|
<template #extra>经纬度不可手动编辑,请使用地图选点自动填充。</template>
|
|
|
<template #help>
|
|
|
<span v-if="formErrors.address || formErrors.lat || formErrors.lng" style="color: #f53f3f;">
|
|
|
{{ formErrors.address || formErrors.lat || formErrors.lng }}
|
|
|
</span>
|
|
|
</template>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="科普场馆图片" required class="admin-modal-form__full">
|
|
|
<div class="venue-cover-carousel-wrap">
|
|
|
<div class="venue-cover-carousel-row__col">
|
|
|
<div class="venue-cover-carousel-row__sub">科普场馆主图 <span style="color: #f53f3f">*</span></div>
|
|
|
<a-space direction="vertical" fill style="width: 100%">
|
|
|
<a-upload :auto-upload="false" :show-file-list="false" accept="image/*" :before-upload="onCoverSelect" @change="onCoverChange">
|
|
|
<template #upload-button><a-button>上传封面</a-button></template>
|
|
|
</a-upload>
|
|
|
<a-typography-text type="secondary">图片尺寸推荐 1200×600</a-typography-text>
|
|
|
<a-space v-if="form.cover_image" direction="vertical" align="start">
|
|
|
<img
|
|
|
:src="resolvePublicMediaUrl(form.cover_image)"
|
|
|
style="width: 80px; border: 1px solid #e5e6eb; border-radius: 4px; cursor: zoom-in"
|
|
|
@error="onImageLoadError"
|
|
|
@click="openMediaPreview('image', form.cover_image)"
|
|
|
/>
|
|
|
<a-button size="mini" status="danger" @click="removeCover">删除封面</a-button>
|
|
|
</a-space>
|
|
|
</a-space>
|
|
|
<div v-if="formErrors.cover_image" style="color: #f53f3f; margin-top: 4px; font-size: 12px;">{{ formErrors.cover_image }}</div>
|
|
|
</div>
|
|
|
<div class="venue-cover-carousel-row__col">
|
|
|
<div class="venue-cover-carousel-row__sub">科普场馆展示图片 <span style="color: #f53f3f">*</span></div>
|
|
|
<div style="display: flex; flex-direction: column; align-items: flex-start; width: 100%">
|
|
|
<div style="width: 100%; margin-bottom: 8px">
|
|
|
<a-upload
|
|
|
:auto-upload="false"
|
|
|
:show-file-list="false"
|
|
|
multiple
|
|
|
accept="image/*,video/*"
|
|
|
:before-upload="onGallerySelect"
|
|
|
@change="onGalleryChange"
|
|
|
>
|
|
|
<template #upload-button><a-button type="primary">新增轮播资源</a-button></template>
|
|
|
</a-upload>
|
|
|
<a-typography-text type="secondary" style="margin-top: 12px; display: block">图片尺寸推荐 1200×600</a-typography-text>
|
|
|
</div>
|
|
|
<div class="venue-gallery-grid">
|
|
|
<div
|
|
|
v-for="(item, idx) in form.gallery_media"
|
|
|
:key="item.url + idx"
|
|
|
class="venue-gallery-item"
|
|
|
>
|
|
|
<img
|
|
|
v-if="item.type === 'image'"
|
|
|
:src="resolvePublicMediaUrl(item.url)"
|
|
|
class="venue-gallery-thumb"
|
|
|
@error="onImageLoadError"
|
|
|
@click="openMediaPreview('image', item.url)"
|
|
|
/>
|
|
|
<video
|
|
|
v-else
|
|
|
:src="resolvePublicMediaUrl(item.url)"
|
|
|
controls
|
|
|
class="venue-gallery-thumb venue-gallery-thumb--video"
|
|
|
@click.stop="openMediaPreview('video', item.url)"
|
|
|
/>
|
|
|
<a-button size="mini" status="danger" @click="removeGallery(idx)">删除</a-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div v-if="formErrors.gallery_media" style="color: #f53f3f; margin-top: 4px; font-size: 12px;">{{ formErrors.gallery_media }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="预约须知" class="admin-modal-form__full">
|
|
|
<RichEditorField
|
|
|
v-model="form.reservation_notice"
|
|
|
:editor-options="reservationEditorOptions"
|
|
|
field-key="venue-reservation"
|
|
|
:key="`venue-reservation-${editorRenderKey}`"
|
|
|
/>
|
|
|
</a-form-item>
|
|
|
<a-form-item label="场馆简介" required class="admin-modal-form__full">
|
|
|
<template #help>
|
|
|
<span v-if="formErrors.detail_html" style="color: #f53f3f;">{{ formErrors.detail_html }}</span>
|
|
|
</template>
|
|
|
<RichEditorField
|
|
|
v-model="form.detail_html"
|
|
|
:editor-options="detailEditorOptions"
|
|
|
field-key="venue-detail"
|
|
|
:min-height="260"
|
|
|
:key="`venue-detail-${editorRenderKey}`"
|
|
|
/>
|
|
|
</a-form-item>
|
|
|
</a-form>
|
|
|
</a-modal>
|
|
|
|
|
|
<a-modal v-model:visible="mediaPreviewVisible" title="媒体预览" width="72%" :footer="false">
|
|
|
<img
|
|
|
v-if="mediaPreviewType === 'image' && mediaPreviewUrl"
|
|
|
:src="mediaPreviewUrl"
|
|
|
style="display: block; max-width: 100%; max-height: 72vh; margin: 0 auto"
|
|
|
@error="onImageLoadError"
|
|
|
/>
|
|
|
<video
|
|
|
v-else-if="mediaPreviewType === 'video' && mediaPreviewUrl"
|
|
|
:src="mediaPreviewUrl"
|
|
|
controls
|
|
|
autoplay
|
|
|
style="display: block; width: 100%; max-height: 72vh"
|
|
|
/>
|
|
|
</a-modal>
|
|
|
|
|
|
<a-modal
|
|
|
v-model:visible="mapVisible"
|
|
|
title="地图选点(腾讯地图)"
|
|
|
width="70%"
|
|
|
:body-style="modalBodyStyle"
|
|
|
:on-before-ok="confirmMapPick"
|
|
|
>
|
|
|
<a-space style="margin-bottom: 12px; width: 100%">
|
|
|
<a-input v-model="mapKeyword" placeholder="输入关键词搜索,如:苏州博物馆" style="width: 420px" />
|
|
|
<a-button type="primary" :loading="mapLoading" @click="searchMapKeyword">搜索</a-button>
|
|
|
</a-space>
|
|
|
<div style="display: flex; gap: 12px">
|
|
|
<div ref="mapContainerRef" style="height: 520px; flex: 1; border: 1px solid #e5e6eb" />
|
|
|
<div style="width: 280px; height: 520px; overflow: auto; border: 1px solid #e5e6eb; padding: 8px">
|
|
|
<div style="margin-bottom: 8px; color: #86909c">搜索结果</div>
|
|
|
<a-list size="small">
|
|
|
<a-list-item v-for="(item, idx) in mapResults" :key="idx" @click="pickSearchResult(item)">
|
|
|
<a-typography-paragraph :ellipsis="{ rows: 1 }">{{ item.title }}</a-typography-paragraph>
|
|
|
<a-typography-text type="secondary">{{ item.address }}</a-typography-text>
|
|
|
</a-list-item>
|
|
|
</a-list>
|
|
|
</div>
|
|
|
</div>
|
|
|
<a-alert v-if="pickedPoint" type="info" style="margin-top: 12px">
|
|
|
已选坐标:{{ pickedPoint.lng }}, {{ pickedPoint.lat }}<br />
|
|
|
地址:{{ pickedPoint.address || '-' }}
|
|
|
</a-alert>
|
|
|
</a-modal>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.venue-address-coord-row {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 12px;
|
|
|
width: 100%;
|
|
|
align-items: center;
|
|
|
}
|
|
|
.venue-address-coord-row__address {
|
|
|
flex: 1 1 45%;
|
|
|
min-width: 320px;
|
|
|
max-width: 100%;
|
|
|
}
|
|
|
.venue-address-coord-row__lng,
|
|
|
.venue-address-coord-row__lat {
|
|
|
width: 200px;
|
|
|
flex: 1 1 180px;
|
|
|
min-width: 180px;
|
|
|
}
|
|
|
.venue-address-coord-row__map {
|
|
|
flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
.venue-form-split-label {
|
|
|
font-size: 13px;
|
|
|
font-weight: 500;
|
|
|
color: var(--color-text-2);
|
|
|
margin-bottom: 8px;
|
|
|
}
|
|
|
|
|
|
.venue-cover-carousel-wrap {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 20px;
|
|
|
width: 100%;
|
|
|
align-items: flex-start;
|
|
|
}
|
|
|
.venue-cover-carousel-row__col {
|
|
|
flex: 1 1 320px;
|
|
|
min-width: min(100%, 320px);
|
|
|
}
|
|
|
.venue-cover-carousel-row__sub {
|
|
|
font-weight: 500;
|
|
|
margin-bottom: 8px;
|
|
|
color: var(--color-text-1);
|
|
|
}
|
|
|
.venue-gallery-grid {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 12px;
|
|
|
width: 100%;
|
|
|
align-items: flex-start;
|
|
|
}
|
|
|
.venue-gallery-item {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
align-items: flex-start;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
.venue-gallery-thumb {
|
|
|
width: 80px;
|
|
|
height: 50px;
|
|
|
object-fit: cover;
|
|
|
border: 1px solid #e5e6eb;
|
|
|
border-radius: 4px;
|
|
|
cursor: zoom-in;
|
|
|
}
|
|
|
.venue-gallery-thumb--video {
|
|
|
display: block;
|
|
|
}
|
|
|
</style>
|