<script setup>
|
import { computed, onMounted, reactive, ref } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
import { ElMessage } from 'element-plus'
|
import { ArrowLeft, Delete, Location, Plus } from '@element-plus/icons-vue'
|
import { getCities } from '@/api/cities'
|
import { createScenicSpot, getScenicSpotDetail, updateScenicSpotDetail } from '@/api/scenicSpots'
|
import AmapMeetingPointMap from '@/components/AmapMeetingPointMap.vue'
|
|
const route = useRoute()
|
const router = useRouter()
|
const isCreate = computed(() => route.name === 'scenic-spot-create')
|
const scenicSpotId = computed(() => Number(route.params.id))
|
const loading = ref(false)
|
const saving = ref(false)
|
const markingIndex = ref(null)
|
const cityOptions = ref([])
|
const selectedCityName = computed(() => (
|
cityOptions.value.find((city) => city.id === form.cityId)?.cityName || form.cityName || ''
|
))
|
|
const defaultPrice = (durationMinutes, minParticipants, maxParticipants = null) => ({
|
durationMinutes,
|
minParticipants,
|
maxParticipants,
|
originalPrice: 0,
|
discountPrice: 0,
|
})
|
const form = reactive({
|
id: null,
|
scenicCode: '',
|
scenicName: '',
|
cityId: null,
|
cityName: '',
|
createdAt: '',
|
groupFast: defaultPrice(30, 20, 30),
|
groupPremium: defaultPrice(60, 20, 30),
|
privateFast: defaultPrice(30, 6),
|
privatePremium: defaultPrice(60, 6),
|
meetingPoints: [],
|
firstOrderLeadMinutes: 15,
|
visitorNoOrderMinutes: 15,
|
unpaidCancelMinutes: 15,
|
})
|
const priceSections = [
|
{ key: 'groupFast', title: '拼团 快讲', group: true },
|
{ key: 'groupPremium', title: '拼团 精讲', group: true },
|
{ key: 'privateFast', title: '包团 快讲', group: false },
|
{ key: 'privatePremium', title: '包团 精讲', group: false },
|
]
|
|
const formatDateTime = (value) => value ? value.replace('T', ' ').slice(0, 19) : '-'
|
const loadCities = async () => {
|
const result = await getCities({ page: 1, size: 100, status: isCreate.value ? 1 : undefined })
|
cityOptions.value = result.records
|
}
|
const loadDetail = async () => {
|
loading.value = true
|
try {
|
const detail = await getScenicSpotDetail(scenicSpotId.value)
|
Object.assign(form, detail)
|
for (const section of priceSections) Object.assign(form[section.key], detail[section.key])
|
form.meetingPoints = (detail.meetingPoints || []).map((point) => ({ ...point }))
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const addMeetingPoint = () => {
|
form.meetingPoints.push({
|
pointName: `汇合点${form.meetingPoints.length + 1}`,
|
longitude: null,
|
latitude: null,
|
status: 1,
|
isDefault: form.meetingPoints.length === 0 ? 1 : 0,
|
})
|
markingIndex.value = form.meetingPoints.length - 1
|
}
|
const removeMeetingPoint = (index) => {
|
const wasDefault = form.meetingPoints[index].isDefault === 1
|
form.meetingPoints.splice(index, 1)
|
if (wasDefault && form.meetingPoints.length > 0) form.meetingPoints[0].isDefault = 1
|
markingIndex.value = null
|
}
|
const startMarking = (index) => {
|
markingIndex.value = index
|
ElMessage.info('请在下方地图点击需要标注的位置')
|
}
|
const markPoint = ({ index, longitude, latitude }) => {
|
if (markingIndex.value !== index || !form.meetingPoints[index]) return
|
form.meetingPoints[index].longitude = longitude
|
form.meetingPoints[index].latitude = latitude
|
markingIndex.value = null
|
ElMessage.success('汇合点已标注')
|
}
|
const placeMeetingPoint = (index, { longitude, latitude }) => {
|
markPoint({ index, longitude, latitude })
|
}
|
|
const validateForm = () => {
|
if (!form.scenicCode.trim()) return '请输入景区编号'
|
if (!/^[A-Za-z0-9_-]+$/.test(form.scenicCode.trim())) return '景区编号仅支持字母、数字、下划线和短横线'
|
if (!form.scenicName.trim()) return '请输入景区名称'
|
if (!form.cityId) return '请选择所属城市'
|
for (const section of priceSections) {
|
const price = form[section.key]
|
if (!Number.isInteger(price.durationMinutes) || price.durationMinutes <= 0) return `${section.title}讲解时间不合法`
|
if (!Number.isInteger(price.minParticipants) || price.minParticipants <= 0) return `${section.title}讲解人数不合法`
|
if (section.group && (!Number.isInteger(price.maxParticipants) || price.maxParticipants < price.minParticipants)) return `${section.title}最多人数不能小于最少人数`
|
if (Number(price.originalPrice) < 0 || Number(price.discountPrice) < 0) return `${section.title}价格不能小于0`
|
if (Number(price.discountPrice) > Number(price.originalPrice)) return `${section.title}优惠价不能高于原价`
|
}
|
for (const point of form.meetingPoints) {
|
if (!point.pointName.trim()) return '请输入汇合点名称'
|
if (point.longitude == null || point.latitude == null) return `请标注汇合点“${point.pointName}”`
|
}
|
if (form.meetingPoints.length > 0 && form.meetingPoints.filter((point) => point.isDefault === 1).length !== 1) {
|
return '请选择一个默认汇合点'
|
}
|
for (const value of [form.firstOrderLeadMinutes, form.visitorNoOrderMinutes, form.unpaidCancelMinutes]) {
|
if (!Number.isInteger(value) || value <= 0) return '导游接单时间配置请输入正整数'
|
}
|
return ''
|
}
|
const saveDetail = async () => {
|
const message = validateForm()
|
if (message) { ElMessage.warning(message); return }
|
saving.value = true
|
try {
|
const payload = {
|
scenicCode: form.scenicCode.trim(),
|
scenicName: form.scenicName.trim(),
|
cityId: form.cityId,
|
groupFast: { ...form.groupFast },
|
groupPremium: { ...form.groupPremium },
|
privateFast: { ...form.privateFast, maxParticipants: null },
|
privatePremium: { ...form.privatePremium, maxParticipants: null },
|
meetingPoints: form.meetingPoints.map(({ pointName, longitude, latitude, status, isDefault }) => ({
|
pointName: pointName.trim(), longitude, latitude, status, isDefault,
|
})),
|
firstOrderLeadMinutes: form.firstOrderLeadMinutes,
|
visitorNoOrderMinutes: form.visitorNoOrderMinutes,
|
unpaidCancelMinutes: form.unpaidCancelMinutes,
|
}
|
const detail = isCreate.value
|
? await createScenicSpot(payload)
|
: await updateScenicSpotDetail(scenicSpotId.value, payload)
|
Object.assign(form, detail)
|
form.meetingPoints = (detail.meetingPoints || []).map((point) => ({ ...point }))
|
ElMessage.success(isCreate.value ? '景区新增成功' : '景区配置已保存')
|
if (isCreate.value) await router.replace('/operations/scenic-spots')
|
} finally {
|
saving.value = false
|
}
|
}
|
|
onMounted(() => isCreate.value ? loadCities() : Promise.all([loadCities(), loadDetail()]))
|
</script>
|
|
<template>
|
<section v-loading="loading" class="scenic-detail-page">
|
<el-button class="back-button" :icon="ArrowLeft" @click="router.push('/operations/scenic-spots')">返回景区列表</el-button>
|
|
<el-card class="section-card" shadow="never">
|
<h2>基本信息</h2>
|
<div class="basic-grid">
|
<el-form-item label="景区编号">
|
<el-input v-model="form.scenicCode" maxlength="32" placeholder="请输入景区编号" />
|
</el-form-item>
|
<el-form-item label="景区名称">
|
<el-input v-model="form.scenicName" maxlength="128" placeholder="请输入景区名称" />
|
</el-form-item>
|
<el-form-item label="新建时间">
|
<el-input :model-value="form.createdAt ? formatDateTime(form.createdAt) : '保存后生成'" disabled />
|
</el-form-item>
|
<el-form-item label="城市">
|
<el-select v-model="form.cityId" filterable placeholder="请选择城市">
|
<el-option v-for="city in cityOptions" :key="city.id" :label="city.cityName" :value="city.id" />
|
</el-select>
|
</el-form-item>
|
</div>
|
</el-card>
|
|
<el-card class="section-card" shadow="never">
|
<h2>价格配置</h2>
|
<div class="price-list">
|
<div v-for="section in priceSections" :key="section.key" class="price-panel">
|
<strong class="price-title">{{ section.title }}</strong>
|
<div class="price-fields">
|
<label>讲解时间(分钟)</label>
|
<el-input-number v-model="form[section.key].durationMinutes" :controls="false" :min="1" :max="1440" :precision="0" />
|
<label>讲解人数</label>
|
<span class="participant-label">最少</span>
|
<el-input-number v-model="form[section.key].minParticipants" :controls="false" :min="1" :precision="0" />
|
<template v-if="section.group">
|
<span class="participant-label">最多</span>
|
<el-input-number v-model="form[section.key].maxParticipants" :controls="false" :min="1" :precision="0" />
|
</template>
|
<label class="original-price-label">原价</label>
|
<el-input-number v-model="form[section.key].originalPrice" :controls="false" :min="0" :precision="2" />
|
<label>优惠价</label>
|
<el-input-number class="discount-price-input" v-model="form[section.key].discountPrice" :controls="false" :min="0" :precision="2" />
|
</div>
|
</div>
|
</div>
|
</el-card>
|
|
<el-card class="section-card" shadow="never">
|
<h2>地图配置</h2>
|
<div class="meeting-list">
|
<div v-for="(point, index) in form.meetingPoints" :key="point.id || index" class="meeting-point-panel">
|
<div class="meeting-toolbar">
|
<span class="meeting-label">汇合点{{ index + 1 }}</span>
|
<el-input v-model="point.pointName" maxlength="128" placeholder="请输入汇合点名称" />
|
<el-button type="primary" :icon="Location" @click="startMarking(index)">
|
标注汇合点
|
</el-button>
|
</div>
|
<div class="meeting-map-row">
|
<AmapMeetingPointMap
|
class="meeting-map"
|
:points="[point]"
|
:active-index="markingIndex === index ? 0 : null"
|
:city-name="selectedCityName"
|
:show-search="false"
|
compact
|
@place="placeMeetingPoint(index, $event)"
|
/>
|
<el-button class="meeting-delete-button" type="primary" :icon="Delete" @click="removeMeetingPoint(index)">
|
删除
|
</el-button>
|
</div>
|
</div>
|
</div>
|
<el-button class="meeting-add-button" type="primary" :icon="Plus" @click="addMeetingPoint">添加汇合点</el-button>
|
</el-card>
|
|
<el-card class="section-card" shadow="never">
|
<h2>导游接单配置</h2>
|
<div class="order-config-grid">
|
<el-form-item label="第一单后开启讲解时间(分钟)">
|
<el-input-number v-model="form.firstOrderLeadMinutes" :controls="false" :min="1" :max="1440" :precision="0" />
|
</el-form-item>
|
<el-form-item label="游客无法下单时间">
|
<el-input-number v-model="form.visitorNoOrderMinutes" :controls="false" :min="1" :max="1440" :precision="0" />
|
</el-form-item>
|
<el-form-item label="未支付自动取消订单时间(分钟)">
|
<el-input-number v-model="form.unpaidCancelMinutes" :controls="false" :min="1" :max="1440" :precision="0" />
|
</el-form-item>
|
</div>
|
<el-button class="save-button" type="primary" :loading="saving" @click="saveDetail">
|
保存
|
</el-button>
|
</el-card>
|
</section>
|
</template>
|
|
<style scoped>
|
.scenic-detail-page { display: flex; flex-direction: column; gap: 14px; }
|
.back-button { align-self: flex-start; }
|
.section-card { border: 0; border-radius: 4px; }
|
.section-card :deep(.el-card__body) { padding: 26px 30px; }
|
.section-card h2 { margin: 0 0 22px; color: #344054; font-size: 16px; }
|
.basic-grid { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 14px 36px; }
|
.basic-grid :deep(.el-form-item) { margin-bottom: 0; }
|
.basic-grid :deep(.el-form-item__label) { width: 76px; color: #475467; }
|
.basic-grid :deep(.el-select) { width: 100%; }
|
.price-list { display: flex; flex-direction: column; gap: 18px; }
|
.price-panel { display: grid; grid-template-columns: 92px 1fr; align-items: start; padding: 22px 24px; background: #f4f7fb; }
|
.price-title { padding-top: 5px; color: #315b96; font-size: 14px; }
|
.price-fields { display: grid; grid-template-columns: 120px 190px 82px 44px 80px 44px 80px; align-items: center; gap: 14px 10px; }
|
.price-fields label { color: #475467; font-size: 13px; }
|
.original-price-label { grid-column: 1; }
|
.discount-price-input { grid-column: 4 / 6; min-width: 120px; }
|
.price-fields :deep(.el-input-number) { width: 100%; }
|
.price-fields :deep(.el-input-number .el-input__inner) { text-align: left; }
|
.participant-label { color: #101828; font-size: 13px; font-weight: 700; }
|
.meeting-list { display: flex; flex-direction: column; gap: 28px; margin-bottom: 28px; }
|
.meeting-point-panel { width: min(100%, 610px); }
|
.meeting-toolbar { display: grid; grid-template-columns: 92px minmax(180px, 263px) 120px; align-items: center; gap: 14px; }
|
.meeting-label { color: #475467; font-size: 14px; font-weight: 500; }
|
.meeting-toolbar :deep(.el-input__wrapper) { background: #f5f7fa; box-shadow: none; }
|
.meeting-toolbar :deep(.el-button) { width: 120px; margin: 0; }
|
.meeting-map-row { display: grid; grid-template-columns: minmax(0, 504px) 88px; align-items: end; gap: 18px; margin-top: 24px; }
|
.meeting-map { min-width: 0; }
|
.meeting-delete-button { width: 88px; margin: 0; }
|
.meeting-add-button { margin: 0; }
|
.order-config-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 0 42px; max-width: 1000px; }
|
.order-config-grid :deep(.el-form-item__label) { width: 240px; color: #475467; white-space: nowrap; }
|
.order-config-grid :deep(.el-input-number) { width: 210px; }
|
.order-config-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
|
.save-button { min-width: 122px; margin: 8px 0 0 0; }
|
@media (max-width: 1360px) {
|
.price-fields { grid-template-columns: 112px 160px 72px 40px 70px 40px 70px; }
|
.basic-grid { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
|
}
|
@media (max-width: 760px) {
|
.meeting-toolbar { grid-template-columns: 82px minmax(0, 1fr); }
|
.meeting-toolbar :deep(.el-button) { grid-column: 2; }
|
.meeting-map-row { grid-template-columns: minmax(0, 1fr) 88px; }
|
}
|
</style>
|