master
lion 5 months ago
parent d7d6b96fe5
commit 5eaebb9186

@ -1,3 +1,4 @@
# 生产构建默认 API(与 szkp-map-h5 同域部署)
# 注意:.env.local 会覆盖本文件,请用 npm run build:prod 或去掉 .env.local 里的 VITE_API_BASE_URL 后再 build
VITE_API_BASE_URL=https://szkp-map.langye.net/api
VITE_PEOPLE_COUNTING_URL=https://hik.pdc.langye.net:18080/api/people-counting

@ -1,3 +1,4 @@
VITE_API_BASE_URL=https://szkp-map.langye.net/api
VITE_TENCENT_MAP_KEY=CRFBZ-NTART-YU4XX-LCDGK-3J456-VKBK2
VITE_TENCENT_MAP_REFERER=szkp-map-admin
VITE_PEOPLE_COUNTING_URL=https://hik.pdc.langye.net:18080/api/people-counting

@ -1,4 +1,5 @@
import axios from 'axios'
import axios, { AxiosError } from 'axios'
import { Message } from '@arco-design/web-vue'
export const TOKEN_KEY = 'szkp_admin_token'
@ -9,6 +10,12 @@ export const http = axios.create({
timeout: 10000,
})
function loginPath() {
const base = import.meta.env.BASE_URL || '/'
const normalized = base.endsWith('/') ? base : `${base}/`
return `${normalized}login`
}
http.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
@ -17,3 +24,21 @@ http.interceptors.request.use((config) => {
return config
})
http.interceptors.response.use(
(res) => res,
(error: AxiosError) => {
const status = error.response?.status
const url = String(error.config?.url ?? '')
const isLoginRequest = url.includes('/auth/login')
if (status === 401 && !isLoginRequest) {
localStorage.removeItem(TOKEN_KEY)
Message.warning('登录已过期,请重新登录')
const next = `${loginPath()}?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`
window.location.replace(next)
}
return Promise.reject(error)
},
)

@ -15,8 +15,10 @@ type MenuItem = {
children?: MenuItem[]
}
type CurrentUser = {
id?: number
name?: string
username?: string
role?: string
}
const router = useRouter()
@ -25,6 +27,13 @@ const route = useRoute()
const collapsed = ref(false)
const menu = ref<MenuItem[]>([])
const currentUser = ref<CurrentUser | null>(null)
const profileVisible = ref(false)
const profileSaving = ref(false)
const profileForm = ref({
name: '',
password: '',
confirmPassword: '',
})
const activePath = computed(() => route.path)
@ -69,6 +78,56 @@ async function loadCurrentUser() {
}
}
function openProfileModal() {
profileForm.value = {
name: currentUser.value?.name || '',
password: '',
confirmPassword: '',
}
profileVisible.value = true
}
function validPassword(pwd: string): boolean {
if (!pwd) return true
if (pwd.length < 8) return false
if (!/[a-z]/.test(pwd)) return false
if (!/[A-Z]/.test(pwd)) return false
if (!/[^A-Za-z0-9]/.test(pwd)) return false
return true
}
async function submitProfile() {
const name = profileForm.value.name.trim()
if (!name) {
Message.warning('姓名不能为空')
return false
}
if (profileForm.value.password && !validPassword(profileForm.value.password)) {
Message.warning('密码需包含大小写字母和特殊字符,且长度至少 8 位')
return false
}
if (profileForm.value.password !== profileForm.value.confirmPassword) {
Message.warning('两次输入的密码不一致')
return false
}
profileSaving.value = true
try {
const payload: { name: string; password?: string } = { name }
if (profileForm.value.password) payload.password = profileForm.value.password
const { data } = await http.put('/me/profile', payload)
currentUser.value = { ...(currentUser.value || {}), ...data }
Message.success('个人信息已更新')
profileVisible.value = false
return true
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '保存失败')
return false
} finally {
profileSaving.value = false
}
}
async function logout() {
try {
await http.post('/auth/logout')
@ -135,6 +194,7 @@ onMounted(async () => {
<div>{{ route.meta?.title ?? '后台管理' }}</div>
<div class="header-actions">
<a-tag color="arcoblue">{{ currentUser?.name || currentUser?.username || '未登录' }}</a-tag>
<a-button type="text" @click="openProfileModal">修改资料</a-button>
<a-button type="text" @click="logout">退出登录</a-button>
</div>
</a-layout-header>
@ -144,6 +204,26 @@ onMounted(async () => {
</a-layout-content>
</a-layout>
</a-layout>
<a-modal
v-model:visible="profileVisible"
title="修改个人资料"
width="520px"
:confirm-loading="profileSaving"
:on-before-ok="submitProfile"
>
<a-form :model="profileForm" layout="vertical">
<a-form-item label="姓名" required>
<a-input v-model="profileForm.name" placeholder="请输入姓名" />
</a-form-item>
<a-form-item label="新密码(留空则不修改)">
<a-input-password v-model="profileForm.password" placeholder="至少 8 位,含大小写字母和特殊字符" />
</a-form-item>
<a-form-item label="确认新密码">
<a-input-password v-model="profileForm.confirmPassword" placeholder="请再次输入新密码" />
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>

@ -51,6 +51,10 @@ router.beforeEach(async (to) => {
if (to.path === '/login') {
if (token) {
const redirect = typeof to.query.redirect === 'string' ? to.query.redirect.trim() : ''
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
return { path: redirect, replace: true }
}
const { getFirstMenuPath } = await import('./dynamicAdminRoutes')
return { path: await getFirstMenuPath(), replace: true }
}

File diff suppressed because it is too large Load Diff

@ -1,10 +1,11 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import { http, TOKEN_KEY } from '../api/http'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const form = reactive({
username: 'admin',
@ -18,7 +19,12 @@ async function onSubmit() {
localStorage.setItem(TOKEN_KEY, data.token)
Message.success('登录成功')
const { getFirstMenuPath } = await import('../router/dynamicAdminRoutes')
router.replace(await getFirstMenuPath())
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect.trim() : ''
const target =
redirect && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: await getFirstMenuPath()
router.replace(target)
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '登录失败')
} finally {

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import RichEditorField from '../../components/RichEditorField.vue'
import { http } from '../../api/http'
@ -8,7 +9,7 @@ import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard
import { listTableRowIndex } from '../../utils/listTableRowIndex'
/** 主列表列宽约 1174px,勿用全局 3220 撑宽列 */
const ACTIVITY_LIST_SCROLL_X = 1420
const ACTIVITY_LIST_SCROLL_X = 1520
type Venue = {
id: number
@ -30,6 +31,7 @@ type Activity = {
contact_name?: string | null
summary?: string
booking_audience?: string | null
location?: string | null
total_quota?: number
start_at?: string
end_at?: string
@ -51,7 +53,6 @@ type Activity = {
audit_status?: 'approved' | 'pending' | 'rejected'
audit_remark?: string | null
reservation_type?: string
location?: string | null
specific_time?: string | null
offline_reservation_method?: string | null
external_url?: string | null
@ -61,6 +62,8 @@ type Activity = {
view_count?: number
/** 外链跳转预约:外链点击 */
external_link_click_count?: number
is_hot?: boolean
behind_scenes_media?: Array<{ type: 'image' | 'video'; url: string }>
}
const rows = ref<Activity[]>([])
@ -79,13 +82,25 @@ const filters = reactive({
audit_status: undefined as 'approved' | 'pending' | 'rejected' | undefined,
})
const rejectActivityId = ref<number | null>(null)
const rejectActivityRemark = ref('')
const rejectActivityVisible = ref(false)
const auditActivityVisible = ref(false)
const auditActivityRecord = ref<Activity | null>(null)
const btsVisible = ref(false)
const btsActivityId = ref<number | null>(null)
const btsModalTitle = ref('上传花絮')
const btsImages = ref<Array<{ type: 'image'; url: string }>>([])
const btsSaving = ref(false)
const isCreate = ref(true)
const editId = ref<number | null>(null)
const formBaseline = ref('')
const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' }
const auditModalBodyStyle = { maxHeight: '72vh', overflow: 'auto' }
/** 上传花絮:固定内容区高度,内层滚动 */
const btsModalBodyStyle = {
height: '520px',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
boxSizing: 'border-box' as const,
}
const editorRenderKey = ref(0)
const mediaPreviewVisible = ref(false)
const mediaPreviewType = ref<'image' | 'video'>('image')
@ -101,6 +116,8 @@ 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 route = useRoute()
/** 接口 ISO8601 → 东八区日历日 YYYY-MM-DD(与后端 APP_TIMEZONE 一致)。勿对 ISO 用 slice(0,10),UTC 日界会导致与后端差一天。 */
function formatYmdInShanghai(iso: string | undefined | null): string {
if (!iso) return ''
@ -127,7 +144,8 @@ function formatActivityTableDateRange(record: Activity): string {
const form = reactive({
venue_id: undefined as number | undefined,
reservation_type: 'phone' as 'phone' | 'wechat_mp' | 'offline_visit',
reservation_type: 'phone' as 'phone' | 'wechat_mp' | 'offline_visit' | 'none',
is_hot: false,
/** 门票说明:免费/收费,提交至 offline_reservation_method */
ticket_note: 'free' as 'free' | 'paid',
location: '',
@ -363,36 +381,131 @@ function scheduleStatusColor(s?: 'not_started' | 'ongoing' | 'ended' | null) {
return 'green'
}
async function approveActivity(row: Activity) {
function openAuditActivity(row: Activity) {
auditActivityRecord.value = row
auditActivityVisible.value = true
}
async function auditSubmitApprove(markHot: boolean) {
const row = auditActivityRecord.value
if (!row?.id) return
try {
await http.post(`/activities/${row.id}/audit/approve`, { mark_hot: markHot })
Message.success('审核已通过')
auditActivityVisible.value = false
await loadAll()
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
}
}
async function auditSubmitReject() {
const row = auditActivityRecord.value
if (!row?.id) return
try {
await http.post(`/activities/${row.id}/audit/approve`)
Message.success('已通过审核')
await http.post(`/activities/${row.id}/audit/reject`, {})
Message.success('已退回')
auditActivityVisible.value = false
await loadAll()
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
}
}
function openRejectActivity(row: Activity) {
rejectActivityId.value = row.id
rejectActivityRemark.value = ''
rejectActivityVisible.value = true
async function onHotFlagChange(row: Activity, val: unknown) {
if (typeof val !== 'boolean') return
try {
await http.post(`/activities/${row.id}/hot-flag`, { is_hot: val })
Message.success('已更新热门状态')
row.is_hot = val
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
}
}
function activityHasBehindScenesImages(row: Activity): boolean {
const raw = row.behind_scenes_media
if (!Array.isArray(raw)) return false
return raw.some((x) => x && x.type === 'image' && String((x as { url?: string }).url || '').trim().length > 0)
}
function openBehindScenesModal(row: Activity) {
btsActivityId.value = row.id
btsModalTitle.value = activityHasBehindScenesImages(row) ? '查看花絮' : '上传花絮'
const raw = Array.isArray(row.behind_scenes_media) ? row.behind_scenes_media : []
btsImages.value = raw
.filter((x) => x && x.type === 'image' && String(x.url || '').trim())
.map((x) => ({ type: 'image' as const, url: String(x.url) }))
btsVisible.value = true
}
async function submitRejectActivity(): Promise<boolean> {
if (!rejectActivityId.value) return false
async function onBtsBeforeUpload(file: unknown) {
try {
await http.post(`/activities/${rejectActivityId.value}/audit/reject`, { remark: rejectActivityRemark.value || undefined })
Message.success('已退回')
rejectActivityVisible.value = false
let f: File | null = file instanceof File ? file : pickUploadFile(file)
if (!f) return false
const url = await uploadFile(f)
btsImages.value = [...btsImages.value, { type: 'image', url }]
Message.success('已添加图片')
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '上传失败')
}
return false
}
function removeBtsImage(i: number) {
const next = [...btsImages.value]
next.splice(i, 1)
btsImages.value = next
}
function moveBtsImage(i: number, dir: -1 | 1) {
const j = i + dir
if (j < 0 || j >= btsImages.value.length) return
const next = [...btsImages.value]
const t = next[i]!
next[i] = next[j]!
next[j] = t
btsImages.value = next
}
async function saveBehindScenes(): Promise<boolean> {
if (!btsActivityId.value) return false
btsSaving.value = true
try {
await http.put(`/activities/${btsActivityId.value}/behind-scenes`, {
behind_scenes_media: btsImages.value,
})
Message.success('花絮已保存')
btsVisible.value = false
await loadAll()
return true
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
Message.error(error?.response?.data?.message ?? '保存失败')
return false
} finally {
btsSaving.value = false
}
}
function auditTicketNoteLabel(row: Activity): string {
const t = row.reservation_type || ''
if (t === 'none') return '—'
const m = String(row.offline_reservation_method || '')
if (m === 'paid') return '收费'
if (m === 'free') return '免费'
return m || '—'
}
function auditCoordCell(v: unknown): string {
const n = parseCoord(v)
return n != null ? n.toFixed(6) : '—'
}
function auditShowExternalUrl(row: Activity): boolean {
const t = row.reservation_type || ''
return t === 'other' || !!String(row.external_url || '').trim()
}
function parseCoord(v: unknown): number | undefined {
if (v === null || v === undefined || v === '') return undefined
const n = typeof v === 'number' ? v : parseFloat(String(v).trim())
@ -578,6 +691,7 @@ function reservationTypeLabel(t?: string | null) {
if (t === 'phone') return '电话预约'
if (t === 'wechat_mp') return '公众号预约'
if (t === 'offline_visit') return '线下预约'
if (t === 'none') return '无需预约'
if (t === 'offline') return '线下预约'
if (t === 'other') return '外链跳转'
if (t === 'online') return '线上预约'
@ -654,6 +768,7 @@ function openCreate() {
form.gallery_media = []
form.tags = []
form.summary = ''
form.is_hot = false
form.sort = 0
form.is_active = true
resetEditors()
@ -677,7 +792,7 @@ function openEdit(row: Activity) {
} else if (t === 'other') {
form.reservation_type = 'wechat_mp'
form.ticket_note = 'free'
} else if (t === 'phone' || t === 'wechat_mp' || t === 'offline_visit') {
} else if (t === 'phone' || t === 'wechat_mp' || t === 'offline_visit' || t === 'none') {
form.reservation_type = t
form.ticket_note = row.offline_reservation_method === 'paid' ? 'paid' : 'free'
} else {
@ -702,6 +817,7 @@ function openEdit(row: Activity) {
form.sort = row.sort ?? 0
form.summary = row.summary || ''
form.is_active = row.is_active
form.is_hot = isSuperAdmin() ? row.is_hot === true : false
resetEditors()
captureFormBaseline()
visible.value = true
@ -712,17 +828,6 @@ function onSearch() {
void loadAll()
}
function resetFilters() {
filters.keyword = ''
filters.venue_id = isVenueAdmin() ? currentUser.value?.venues?.[0]?.id : undefined
filters.reservation_type = undefined
filters.is_active = undefined
filters.schedule_status = undefined
filters.audit_status = undefined
pagination.current = 1
void loadAll()
}
function pickUploadFile(payload: any): File | null {
const visited = new Set<any>()
const queue: any[] = [payload]
@ -836,7 +941,7 @@ function validateForm(): boolean {
formErrors.location = '请填写活动地点'
isValid = false
}
if (!form.ticket_note) {
if (!form.ticket_note && form.reservation_type !== 'none') {
formErrors.ticket_note = '请选择门票说明'
isValid = false
}
@ -856,7 +961,8 @@ async function submit() {
reservation_type: form.reservation_type,
location: form.location.trim(),
specific_time: form.specific_time.trim() || null,
offline_reservation_method: form.ticket_note === 'paid' ? 'paid' : 'free',
offline_reservation_method:
form.reservation_type === 'none' ? null : form.ticket_note === 'paid' ? 'paid' : 'free',
external_url: null,
title: form.title.trim(),
summary: form.summary.trim() || null,
@ -876,6 +982,7 @@ async function submit() {
reservation_notice: null,
open_time: null,
sort: isSuperAdmin() ? form.sort : undefined,
is_hot: isSuperAdmin() ? form.is_hot : undefined,
is_active: form.is_active,
}
if (isCreate.value) {
@ -899,6 +1006,11 @@ useUnsavedChangesGuard(hasUnsavedChanges, '活动表单有未保存改动,确
onMounted(async () => {
await loadMe().catch(() => undefined)
const q = route.query.audit_status
const audit = Array.isArray(q) ? q[0] : q
if (audit === 'pending' || audit === 'approved' || audit === 'rejected') {
filters.audit_status = audit
}
await loadAll()
})
@ -938,12 +1050,12 @@ async function removeActivity(row: Activity) {
<a-space style="margin-bottom: 12px" wrap>
<a-input v-model="filters.keyword" placeholder="搜索活动名称" style="width: 220px" />
<a-select
v-if="!isVenueAdmin()"
v-model="filters.venue_id"
allow-clear
allow-search
placeholder="筛选场馆"
style="width: 220px"
:disabled="isVenueAdmin()"
>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
@ -960,6 +1072,7 @@ async function removeActivity(row: Activity) {
<a-option value="phone">电话预约</a-option>
<a-option value="wechat_mp">公众号预约</a-option>
<a-option value="offline_visit">线下预约</a-option>
<a-option value="none">无需预约</a-option>
</a-select>
<a-select v-model="filters.audit_status" allow-clear placeholder="审核状态" style="width: 130px">
<a-option value="approved">已通过</a-option>
@ -967,7 +1080,6 @@ async function removeActivity(row: Activity) {
<a-option value="rejected">已退回</a-option>
</a-select>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
<a-button type="primary" @click="openCreate">新增活动</a-button>
</a-space>
@ -1026,14 +1138,27 @@ async function removeActivity(row: Activity) {
<a-tag v-else :color="auditStatusColor(record.audit_status)">{{ auditStatusLabel(record.audit_status) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="300" :min-width="280" fixed="right" align="left">
<a-table-column v-if="isSuperAdmin()" title="热门" :width="80">
<template #cell="{ record }">
<a-switch
:model-value="(record as Activity).is_hot === true"
size="small"
@change="(v: unknown) => onHotFlagChange(record as Activity, v)"
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="340" :min-width="320" fixed="right" align="left">
<template #cell="{ record }">
<a-space wrap :size="4" 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="approveActivity(record)">通过</a-button>
<a-button type="text" status="danger" @click="openRejectActivity(record)">退回</a-button>
<a-button type="text" @click="openAuditActivity(record)">审核</a-button>
</template>
<a-button
v-if="record.schedule_status === 'ended'"
type="text"
@click="openBehindScenesModal(record as Activity)"
>{{ activityHasBehindScenesImages(record as Activity) ? '查看花絮' : '上传花絮' }}</a-button>
<a-button type="text" status="warning" @click="toggleStatus(record)">{{ record.is_active ? '下架' : '上架' }}</a-button>
<a-popconfirm content="确认删除该活动?" @ok="removeActivity(record)">
<a-button type="text" status="danger">删除</a-button>
@ -1045,8 +1170,194 @@ async function removeActivity(row: Activity) {
</a-table>
</a-card>
<a-modal v-model:visible="rejectActivityVisible" title="退回活动" :on-before-ok="submitRejectActivity">
<a-textarea v-model="rejectActivityRemark" placeholder="退回说明(选填)" :auto-size="{ minRows: 3, maxRows: 8 }" />
<a-modal
v-model:visible="auditActivityVisible"
title="审核活动"
width="72%"
:body-style="auditModalBodyStyle"
>
<template #footer>
<a-space wrap>
<a-button type="primary" status="success" @click="auditSubmitApprove(false)">通过</a-button>
<a-button type="primary" @click="auditSubmitApprove(true)">通过且为热门活动</a-button>
<a-button status="danger" @click="auditSubmitReject">退回</a-button>
<a-button @click="auditActivityVisible = false">取消</a-button>
</a-space>
</template>
<div v-if="auditActivityRecord" class="activity-audit-readonly">
<div class="activity-audit-inline">
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">活动名称</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.title || '—' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">举办场馆</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.venue?.name || '—' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">联系人</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.contact_name || '—' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">联系电话</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.contact_phone || '—' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">活动日期</span>
<div class="activity-audit-inline-value">{{ formatActivityTableDateRange(auditActivityRecord) }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">具体时间</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.specific_time?.trim() ? auditActivityRecord.specific_time : '—' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">报名方式</span>
<div class="activity-audit-inline-value">{{ reservationTypeLabel(auditActivityRecord.reservation_type) }}</div>
</div>
<div v-if="auditActivityRecord.reservation_type !== 'none'" class="activity-audit-inline-row">
<span class="activity-audit-inline-label">门票说明</span>
<div class="activity-audit-inline-value">{{ auditTicketNoteLabel(auditActivityRecord) }}</div>
</div>
<div v-if="auditShowExternalUrl(auditActivityRecord)" class="activity-audit-inline-row">
<span class="activity-audit-inline-label">外链地址</span>
<div class="activity-audit-inline-value activity-audit-break">{{ auditActivityRecord.external_url || '—' }}</div>
</div>
<div class="activity-audit-inline-row activity-audit-inline-row--tags">
<span class="activity-audit-inline-label">标签</span>
<div class="activity-audit-inline-value">
<div class="activity-form-tags__chips activity-form-tags__chips--readonly activity-form-tags__chips--inline">
<a-tag v-for="(tag, index) in auditActivityRecord.tags || []" :key="`${tag}-${index}`" style="margin-bottom: 0">
{{ tag }}
</a-tag>
<span v-if="!auditActivityRecord.tags?.length" class="activity-form-tags__empty">暂无标签</span>
</div>
</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">活动状态</span>
<div class="activity-audit-inline-value">
<a-tag v-if="auditActivityRecord.schedule_status" :color="scheduleStatusColor(auditActivityRecord.schedule_status)">
{{ scheduleStatusLabel(auditActivityRecord.schedule_status) }}
</a-tag>
<span v-else class="activity-audit-muted">—</span>
</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">上架状态</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.is_active ? '上架' : '下架' }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">排序</span>
<div class="activity-audit-inline-value">{{ String(auditActivityRecord.sort ?? 0) }}</div>
</div>
<div v-if="isSuperAdmin()" class="activity-audit-inline-row">
<span class="activity-audit-inline-label">热门活动</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.is_hot === true ? '是' : '否' }}</div>
</div>
</div>
<div class="activity-audit-stack">
<div class="activity-audit-stack__label">活动地点</div>
<div class="activity-audit-stack__body">
<div class="activity-address-coord-row">
<div class="activity-address-coord-row__address activity-audit-static-text activity-audit-static-text--fill">
{{ auditActivityRecord.location?.trim() ? auditActivityRecord.location : (auditActivityRecord.address || '—') }}
</div>
<div class="activity-address-coord-row__lng activity-audit-static-coord">{{ auditCoordCell(auditActivityRecord.lng) }}</div>
<div class="activity-address-coord-row__lat activity-audit-static-coord">{{ auditCoordCell(auditActivityRecord.lat) }}</div>
</div>
</div>
</div>
<div class="activity-audit-stack">
<div class="activity-audit-stack__label">活动图片</div>
<div class="activity-audit-stack__body">
<div class="activity-cover-carousel-wrap">
<div class="activity-cover-carousel-row__col">
<div class="activity-cover-carousel-row__sub">活动封面图</div>
<template v-if="auditActivityRecord.cover_image">
<img
:src="auditActivityRecord.cover_image"
class="activity-cover-thumb"
alt=""
@click="openMediaPreview('image', auditActivityRecord.cover_image!)"
/>
</template>
<span v-else class="activity-audit-muted">—</span>
</div>
<div class="activity-cover-carousel-row__col">
<div class="activity-cover-carousel-row__sub">活动展示图片</div>
<div v-if="auditActivityRecord.gallery_media?.length" class="activity-gallery-grid">
<div
v-for="(item, idx) in auditActivityRecord.gallery_media"
:key="item.url + idx"
class="activity-gallery-item"
>
<img
v-if="item.type === 'image'"
:src="item.url"
class="activity-gallery-thumb"
alt=""
@click="openMediaPreview('image', item.url)"
/>
<video
v-else
:src="item.url"
controls
class="activity-gallery-thumb activity-gallery-thumb--video"
@click.stop="openMediaPreview('video', item.url)"
/>
</div>
</div>
<span v-else class="activity-audit-muted">—</span>
</div>
</div>
</div>
</div>
<div class="activity-audit-stack">
<div class="activity-audit-stack__label">活动详情</div>
<div class="activity-audit-stack__body">
<div
v-if="auditActivityRecord.detail_html?.trim()"
class="activity-audit-rich"
v-html="auditActivityRecord.detail_html"
/>
<span v-else class="activity-audit-muted">—</span>
</div>
</div>
</div>
</a-modal>
<a-modal
v-model:visible="btsVisible"
:title="btsModalTitle"
width="1000px"
:body-style="btsModalBodyStyle"
:confirm-loading="btsSaving"
:on-before-ok="saveBehindScenes"
>
<a-space direction="vertical" fill style="width: 100%">
<a-upload
:auto-upload="false"
:show-file-list="false"
accept="image/*"
multiple
:on-before-upload="onBtsBeforeUpload"
>
<template #upload-button><a-button type="outline">添加图片</a-button></template>
</a-upload>
<div v-if="btsImages.length" class="bts-preview-grid">
<div v-for="(img, i) in btsImages" :key="i + '-' + img.url" class="bts-preview-item">
<img class="bts-preview-img" :src="img.url" @click="openMediaPreview('image', img.url)" />
<div class="bts-preview-actions">
<a-button size="mini" class="bts-act-btn" :disabled="i === 0" @click="moveBtsImage(i, -1)">上移</a-button>
<a-button size="mini" class="bts-act-btn" :disabled="i === btsImages.length - 1" @click="moveBtsImage(i, 1)">下移</a-button>
<a-button size="mini" class="bts-act-btn" status="danger" @click="removeBtsImage(i)">删除</a-button>
</div>
</div>
</div>
</a-space>
</a-modal>
<a-modal
@ -1109,12 +1420,13 @@ async function removeActivity(row: Activity) {
<a-option value="phone">电话预约</a-option>
<a-option value="wechat_mp">公众号预约</a-option>
<a-option value="offline_visit">线下预约</a-option>
<a-option value="none">无需预约</a-option>
</a-select>
<template v-if="formErrors.reservation_type" #help>
<span style="color: #f53f3f;">{{ formErrors.reservation_type }}</span>
</template>
</a-form-item>
<a-form-item label="门票说明" required :help="formErrors.ticket_note">
<a-form-item v-if="form.reservation_type !== 'none'" label="门票说明" required :help="formErrors.ticket_note">
<a-select v-model="form.ticket_note">
<a-option value="free">免费</a-option>
<a-option value="paid">收费</a-option>
@ -1162,6 +1474,9 @@ async function removeActivity(row: Activity) {
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="isSuperAdmin()" label="热门活动">
<a-switch v-model="form.is_hot" />
</a-form-item>
<a-form-item label="活动地点" required class="admin-modal-form__full" :help="formErrors.location">
<div class="activity-address-coord-row">
<a-input
@ -1412,5 +1727,158 @@ async function removeActivity(row: Activity) {
color: #86909c;
font-size: 12px;
}
.activity-form-tags__chips--readonly {
margin-top: 0;
}
.activity-form-tags__chips--inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.activity-audit-inline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 16px;
row-gap: 10px;
padding-bottom: 4px;
}
.activity-audit-inline-row {
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 14px;
line-height: 1.5715;
min-width: 0;
}
.activity-audit-inline-row--tags {
align-items: center;
}
.activity-audit-inline-label {
flex: 0 0 74px;
color: var(--color-text-3);
text-align: right;
}
.activity-audit-inline-value {
flex: 1;
min-width: 0;
color: var(--color-text-1);
word-break: break-word;
}
.activity-audit-stack {
margin-top: 20px;
}
.activity-audit-stack__label {
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
font-size: 14px;
}
.activity-audit-stack__body {
min-width: 0;
}
@media (max-width: 1400px) {
.activity-audit-inline {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.activity-audit-inline {
grid-template-columns: minmax(0, 1fr);
}
}
.activity-audit-static-text {
color: var(--color-text-1);
font-size: 14px;
line-height: 1.5715;
word-break: break-word;
}
.activity-audit-static-text--fill {
min-height: 32px;
padding: 4px 11px;
border: 1px solid var(--color-neutral-3);
border-radius: var(--border-radius-small);
background: var(--color-fill-2);
box-sizing: border-box;
display: flex;
align-items: center;
}
.activity-audit-static-coord {
min-height: 32px;
padding: 4px 11px;
border: 1px solid var(--color-neutral-3);
border-radius: var(--border-radius-small);
background: var(--color-fill-2);
box-sizing: border-box;
color: var(--color-text-2);
font-size: 14px;
line-height: 1.5715;
display: flex;
align-items: center;
font-variant-numeric: tabular-nums;
}
.activity-audit-muted {
color: var(--color-text-3);
font-size: 13px;
}
.activity-audit-break {
word-break: break-word;
}
.activity-audit-rich {
font-size: 13px;
line-height: 1.7;
color: var(--color-text-1);
border: 1px solid #e5e6eb;
border-radius: 8px;
padding: 12px;
max-height: 380px;
overflow-y: auto;
background: #fafafa;
}
.activity-audit-rich :deep(img) {
max-width: 100%;
height: auto;
vertical-align: top;
}
.activity-audit-rich :deep(video) {
max-width: 100%;
}
.bts-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
width: 100%;
}
.bts-preview-item {
flex: 0 0 auto;
width: 220px;
box-sizing: border-box;
}
.bts-preview-img {
width: 220px;
height: 220px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e5e6eb;
cursor: zoom-in;
display: block;
}
.bts-preview-actions {
margin-top: 8px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
}
.bts-preview-actions :deep(.bts-act-btn.arco-btn-size-mini) {
flex: 1 1 0;
min-width: 0;
padding-left: 6px;
padding-right: 6px;
}
</style>

@ -176,11 +176,10 @@ onMounted(async () => {
<div class="um-toolbar">
<a-space wrap :size="12">
<a-input v-model="keyword" placeholder="姓名/手机号/身份证" allow-clear style="width: 220px" />
<a-select v-model="selectedVenueId" :disabled="isVenueAdmin" allow-clear style="width: 220px" placeholder="筛选场馆">
<a-select v-if="!isVenueAdmin" v-model="selectedVenueId" allow-clear style="width: 220px" placeholder="筛选场馆">
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
</a-select>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button @click="loadRows">刷新</a-button>
<a-button status="success" @click="openBatchAction('unblacklist')">批量移出灰名单</a-button>
</a-space>
</div>

@ -13,6 +13,10 @@ type Venue = {
name: string
}
type CurrentUser = {
role: string
}
type StudyTour = {
id: number
name: string
@ -31,6 +35,7 @@ const isCreate = ref(true)
const editId = ref<number | null>(null)
const rows = ref<StudyTour[]>([])
const venues = ref<Venue[]>([])
const currentUser = ref<CurrentUser | null>(null)
const filterKeyword = ref('')
const filterVenueId = ref<number | undefined>(undefined)
const filterOnShelf = ref<string | undefined>(undefined)
@ -215,12 +220,13 @@ function onImageLoadError(ev?: Event) {
Message.error('图片地址无法访问,请检查后端 storage 访问配置')
}
function resetFilters() {
filterKeyword.value = ''
filterVenueId.value = undefined
filterOnShelf.value = undefined
listPagination.current = 1
void loadData()
function isVenueAdmin() {
return currentUser.value?.role === 'venue_admin'
}
async function loadMe() {
const { data } = await http.get('/me')
currentUser.value = data
}
async function loadData(keepCurrentPage = false) {
@ -391,7 +397,10 @@ async function removeRow(row: StudyTour) {
}
}
onMounted(loadData)
onMounted(async () => {
await loadMe().catch(() => undefined)
await loadData()
})
</script>
<template>
@ -406,6 +415,7 @@ onMounted(loadData)
@clear="loadData"
/>
<a-select
v-if="!isVenueAdmin()"
v-model="filterVenueId"
placeholder="场馆"
allow-clear
@ -420,7 +430,6 @@ onMounted(loadData)
<a-option value="0">下架</a-option>
</a-select>
<a-button type="primary" @click="() => loadData()">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
<a-button type="primary" @click="openCreate">新建线路</a-button>
</a-space>

@ -287,6 +287,10 @@ function isSuperAdmin() {
return currentUser.value?.full_admin_access === true
}
function isVenueAdmin() {
return currentUser.value?.role === 'venue_admin'
}
function addTag() {
const v = tagInput.value.trim()
if (!v) {
@ -1110,6 +1114,7 @@ onMounted(async () => {
<a-space wrap :size="12">
<a-input v-model="keyword" placeholder="按名称搜索" allow-clear style="width: 180px" />
<a-select
v-if="!isVenueAdmin()"
v-model="filterVenueId"
placeholder="场馆"
allow-clear
@ -1131,7 +1136,6 @@ onMounted(async () => {
</a-select>
<a-button type="primary" @click="() => ((pagination.current = 1), loadRows())">查询</a-button>
<a-button type="primary" @click="openCreate">新建抢票</a-button>
<a-button @click="loadRows">刷新</a-button>
</a-space>
<a-table

@ -127,20 +127,6 @@ onMounted(async () => {
>
查询
</a-button>
<a-button
@click="
() => {
status = 'all'
keyword = ''
ticketGrabEventId = undefined
dateRange = []
pagination.current = 1
loadRows()
}
"
>
重置
</a-button>
</a-space>
<a-table

@ -1,5 +1,6 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import { http } from '../../api/http'
import RichEditorField from '../../components/RichEditorField.vue'
@ -81,6 +82,8 @@ const pickedPoint = ref<{ lat: number; lng: number; address: string } | null>(nu
const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 } // 苏州市人民政府
const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' }
const route = useRoute()
// 表单验证错误信息
const formErrors = reactive<Record<string, string>>({
name: '',
@ -134,21 +137,6 @@ const rejectVenueId = ref<number | null>(null)
const rejectVenueRemark = ref('')
const rejectVenueVisible = ref(false)
const importModalVisible = ref(false)
const importPreviewLoading = ref(false)
const importConfirmLoading = ref(false)
type ImportPreviewRow = {
row_index: number
ok: boolean
errors: string[]
payload: Record<string, unknown>
/** 有效行时:create=新增,update=与已有场馆同名将覆盖更新 */
import_action?: 'create' | 'update' | null
}
const importPreviewData = ref<{ rows: ImportPreviewRow[]; summary: { total: number; valid: number; invalid: number } } | null>(
null,
)
function parseCoord(v: unknown): number | undefined {
if (v === null || v === undefined || v === '') return undefined
const n = typeof v === 'number' ? v : parseFloat(String(v).trim())
@ -643,20 +631,6 @@ function onPageChange(p: number) {
pagination.current = p
}
function resetFilters() {
filters.keyword = ''
filters.district = ''
filters.venue_type = ''
filters.ticket_type = ''
filters.booking_mode = ''
filters.open_mode = ''
filters.is_included_in_stats = ''
filters.is_active = ''
filters.audit_status = ''
pagination.current = 1
void loadRows()
}
async function ensureMapSdkLoaded() {
const w = window as any
if (w.TMap) return
@ -930,159 +904,13 @@ async function onBeforeOk() {
return await submit()
}
async function downloadImportTemplate() {
try {
const res = await http.get('/venues/import/template', { responseType: 'blob', timeout: 60000 })
const blob = res.data as Blob
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '场馆导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '下载失败')
}
}
// 导出场馆功能已暂时隐藏,如需恢复请取消按钮注释
// async function exportVenues() {
// try {
// const res = await http.get('/venues/export', { responseType: 'blob', timeout: 120000 })
// const blob = res.data as Blob
// if (blob.type && blob.type.includes('json')) {
// const text = await blob.text()
// let msg = '导出失败'
// try {
// const j = JSON.parse(text) as { message?: string }
// if (j?.message) msg = j.message
// } catch {
// // ignore
// }
// Message.error(msg)
// return
// }
// const url = URL.createObjectURL(blob)
// const a = document.createElement('a')
// a.href = url
// a.download = `场馆导出-${new Date().toISOString().slice(0, 10)}.csv`
// a.click()
// URL.revokeObjectURL(url)
// Message.success('导出成功')
// } catch (e: any) {
// Message.error(e?.response?.data?.message ?? '导出失败')
// }
// }
function openImportModal() {
importPreviewData.value = null
importModalVisible.value = true
}
async function onImportFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
importPreviewLoading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const { data } = await http.post('/venues/import/preview', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000,
})
importPreviewData.value = data
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '解析失败')
} finally {
importPreviewLoading.value = false
}
}
function importPreviewVenueName(payload: Record<string, unknown>) {
return String(payload.name ?? '')
}
function importPreviewDictLabel(options: DictItem[], value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
const s = String(value)
const hit = options.find((o) => o.item_value === s)
return hit?.item_label ?? s
}
/** 解析失败时仍显示 Excel 原文字段,便于核对 */
function importPreviewOrRaw(
options: DictItem[],
valueKey: string,
rawKey: string,
payload: Record<string, unknown>,
): string {
const v = payload[valueKey]
if (v !== null && v !== undefined && String(v).trim() !== '') {
return importPreviewDictLabel(options, v)
}
const raw = payload[rawKey]
return raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : '—'
}
function importPreviewThemes(payload: Record<string, unknown>): string {
const vt = payload.venue_types
if (!Array.isArray(vt) || vt.length === 0) return '—'
const parts = vt.map((x) => importPreviewDictLabel(venueTypeOptions.value, x)).filter((s) => s !== '—')
return parts.length ? parts.join(',') : '—'
}
function importPreviewBoolText(v: unknown): string {
if (v === true) return '是'
if (v === false) return '否'
return '—'
}
const IMPORT_PREVIEW_SCROLL_X = 3920
function importPreviewSnippet(text: unknown, maxLen: number): string {
if (text === null || text === undefined) return '—'
const s = String(text).trim()
if (!s) return '—'
const plain = s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (!plain) return '(富文本)'
return plain.length > maxLen ? `${plain.slice(0, maxLen)}…` : plain
}
async function confirmVenueImport() {
const rows = importPreviewData.value?.rows?.filter((r) => r.ok).map((r) => r.payload) ?? []
if (!rows.length) {
Message.warning('没有可导入的有效行')
return
}
importConfirmLoading.value = true
try {
const { data } = await http.post<{
created_count?: number
updated_count?: number
}>('/venues/import/confirm', { rows })
const c = data?.created_count ?? 0
const u = data?.updated_count ?? 0
Message.success(
u > 0 && c > 0
? `导入成功:新增 ${c} 条,更新 ${u} 条`
: u > 0
? `导入成功:更新 ${u} 条`
: `导入成功:新增 ${c} 条`,
)
importModalVisible.value = false
importPreviewData.value = null
await loadRows()
} catch (e: any) {
Message.error(e?.response?.data?.message ?? '导入失败')
} finally {
importConfirmLoading.value = false
}
}
onMounted(async () => {
await loadMe()
const q = route.query.audit_status
const audit = Array.isArray(q) ? q[0] : q
if (audit === 'pending' || audit === 'approved' || audit === 'rejected') {
filters.audit_status = audit
}
await Promise.all([
loadRows(),
loadDistricts(),
@ -1129,17 +957,11 @@ onMounted(async () => {
<a-option value="rejected">已退回</a-option>
</a-select>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
<a-button type="primary" @click="openCreate">新增场馆</a-button>
<!-- <a-button v-if="isSuperAdmin()" @click="exportVenues">导出场馆</a-button> -->
<a-button v-if="isSuperAdmin()" @click="openImportModal">导入场馆</a-button>
</a-space>
</template>
<a-alert style="margin-bottom: 12px">
场馆管理员账号仅能看到和编辑自己绑定的场馆;超级管理员可管理全部场馆。
</a-alert>
<a-table
class="list-data-table"
:scroll="{ x: VENUE_LIST_SCROLL_X }"
@ -1229,174 +1051,6 @@ onMounted(async () => {
</a-table>
</a-card>
<a-modal v-model:visible="importModalVisible" title="导入场馆" width="80%" :footer="false" :body-style="modalBodyStyle">
<a-space direction="vertical" fill style="width: 100%">
<a-alert type="info">
请先下载模板,按「选项」工作表中的下拉值填写;多个主题用英文逗号分隔。封面与轮播图不在导入范围内,请在列表中编辑上传。经纬度可留空,导入后请在列表编辑中通过「地图选点」填写;经度与纬度须成对填写或同时留空。
</a-alert>
<a-space wrap>
<a-button type="primary" @click="downloadImportTemplate">下载导入模板</a-button>
<label class="import-file-label">
<span class="import-file-btn">选择 Excel 并预览</span>
<input class="import-file-input" type="file" accept=".xlsx,.xls" @change="onImportFileChange" />
</label>
</a-space>
<a-spin :loading="importPreviewLoading" style="width: 100%">
<template v-if="importPreviewData">
<a-typography-text type="secondary">
共 {{ importPreviewData.summary.total }} 行,有效 {{ importPreviewData.summary.valid }},无效 {{ importPreviewData.summary.invalid }}
</a-typography-text>
<a-table
:data="importPreviewData.rows"
:pagination="false"
size="small"
:scroll="{ x: IMPORT_PREVIEW_SCROLL_X }"
class="venue-import-preview-table"
style="margin-top: 12px"
>
<template #columns>
<a-table-column title="行号" :width="64" fixed="left">
<template #cell="{ record }">{{ record.row_index }}</template>
</a-table-column>
<a-table-column title="结果" :width="76" fixed="left">
<template #cell="{ record }">
<a-tag :color="record.ok ? 'green' : 'red'">{{ record.ok ? 'OK' : '错误' }}</a-tag>
</template>
</a-table-column>
<a-table-column title="导入" :width="88" fixed="left">
<template #cell="{ record }">
<a-tag v-if="record.ok && record.import_action === 'update'" color="orangered">更新</a-tag>
<a-tag v-else-if="record.ok && record.import_action === 'create'" color="arcoblue">新增</a-tag>
<span v-else>—</span>
</template>
</a-table-column>
<a-table-column title="校验说明" :width="220" fixed="left">
<template #cell="{ record }">
<a-typography-text
v-if="!record.ok"
type="danger"
:ellipsis="{ rows: 3, showTooltip: true }"
>
{{ record.errors.join(';') }}
</a-typography-text>
<span v-else>—</span>
</template>
</a-table-column>
<a-table-column title="场馆名称" :width="140" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ importPreviewVenueName(record.payload) }}</template>
</a-table-column>
<a-table-column title="主题" :width="180" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ importPreviewThemes(record.payload) }}</template>
</a-table-column>
<a-table-column title="行政区" :width="100" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.district ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="预约类型" :width="120">
<template #cell="{ record }">
{{
importPreviewOrRaw(
appointmentTypeOptions,
'appointment_type',
'_raw_appointment_label',
record.payload,
)
}}
</template>
</a-table-column>
<a-table-column title="门票类型" :width="100">
<template #cell="{ record }">
{{
importPreviewOrRaw(ticketTypeOptions, 'ticket_type', '_raw_ticket_type_label', record.payload)
}}
</template>
</a-table-column>
<a-table-column title="预约模式" :width="130">
<template #cell="{ record }">
{{ importPreviewOrRaw(bookingModeOptions, 'booking_mode', '_raw_booking_mode_label', record.payload) }}
</template>
</a-table-column>
<a-table-column title="开放模式" :width="130">
<template #cell="{ record }">
{{ importPreviewOrRaw(openModeOptions, 'open_mode', '_raw_open_mode_label', record.payload) }}
</template>
</a-table-column>
<a-table-column title="所属单位" :width="120" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.unit_name ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="预约方式" :width="140" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ listCellOneLine(String(record.payload.booking_method ?? '')) }}</template>
</a-table-column>
<a-table-column title="参观形式" :width="120" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.visit_form ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="开放时间" :width="130" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.open_time ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="咨询预约时间" :width="130" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.consultation_hours ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="咨询预约联系电话" :width="120" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.contact_phone ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="排序" :width="64">
<template #cell="{ record }">{{ record.payload.sort === 0 || record.payload.sort ? String(record.payload.sort) : '—' }}</template>
</a-table-column>
<a-table-column title="启用" :width="64">
<template #cell="{ record }">{{ importPreviewBoolText(record.payload.is_active) }}</template>
</a-table-column>
<a-table-column title="纳入统计" :width="80">
<template #cell="{ record }">{{ importPreviewBoolText(record.payload.is_included_in_stats) }}</template>
</a-table-column>
<a-table-column title="场馆地址" :width="160" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ String(record.payload.address ?? '') || '—' }}</template>
</a-table-column>
<a-table-column title="经度" :width="100">
<template #cell="{ record }">{{ record.payload.lng != null && record.payload.lng !== '' ? String(record.payload.lng) : '—' }}</template>
</a-table-column>
<a-table-column title="纬度" :width="100">
<template #cell="{ record }">{{ record.payload.lat != null && record.payload.lat !== '' ? String(record.payload.lat) : '—' }}</template>
</a-table-column>
<a-table-column title="门票说明" :width="180" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">
<a-tooltip :content="String(record.payload.ticket_content ?? '')">
<span>{{ importPreviewSnippet(record.payload.ticket_content, 48) }}</span>
</a-tooltip>
</template>
</a-table-column>
<a-table-column title="场馆详情" :width="200">
<template #cell="{ record }">
<a-tooltip :content="String(record.payload.detail_html ?? '')">
<span>{{ importPreviewSnippet(record.payload.detail_html, 40) }}</span>
</a-tooltip>
</template>
</a-table-column>
<a-table-column title="预约须知" :width="200" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">
<a-tooltip :content="String(record.payload.reservation_notice ?? '')">
<span>{{ importPreviewSnippet(record.payload.reservation_notice, 48) }}</span>
</a-tooltip>
</template>
</a-table-column>
</template>
</a-table>
<div style="margin-top: 16px; text-align: right">
<a-space>
<a-button @click="importModalVisible = false">取消</a-button>
<a-button
type="primary"
:loading="importConfirmLoading"
:disabled="importPreviewData.summary.valid === 0"
@click="confirmVenueImport"
>
确认导入
</a-button>
</a-space>
</div>
</template>
</a-spin>
</a-space>
</a-modal>
<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>
@ -1718,29 +1372,6 @@ onMounted(async () => {
</template>
<style scoped>
.import-file-label {
cursor: pointer;
display: inline-block;
}
.import-file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
}
.import-file-btn {
display: inline-block;
padding: 0 15px;
line-height: 32px;
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-small);
}
.import-file-label:hover .import-file-btn {
border-color: rgb(var(--primary-6));
color: rgb(var(--primary-6));
}
.venue-address-coord-row {
display: flex;
flex-wrap: wrap;

Loading…
Cancel
Save