You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1408 lines
48 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>