liujie
2026-09-09 39f6fca28e3f78abe48f08e474ec011abb990b36
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
<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>