| | |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import L, { type Map as LeafletMap, type Marker } from 'leaflet' |
| | | import 'leaflet/dist/leaflet.css' |
| | | import { load as loadAMapApi } from '@amap/amap-jsapi-loader' |
| | | import { Minus, Picture, Plus, Refresh, Search } from '@element-plus/icons-vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import type { FormInstance, FormRules } from 'element-plus' |
| | |
| | | const locationSearching = ref(false) |
| | | const locationSearchResults = ref<Array<{ name: string; longitude: number; latitude: number }>>([]) |
| | | const mapElement = ref<HTMLElement>() |
| | | let locationMap: LeafletMap | undefined |
| | | let locationMarker: Marker | undefined |
| | | const mapMarkerIcon = L.divIcon({ className: 'merchant-map-marker', html: '<span></span>', iconSize: [26, 26], iconAnchor: [13, 26] }) |
| | | let locationMap: any |
| | | let locationMarker: any |
| | | let amapApi: any |
| | | |
| | | async function loadAMap() { |
| | | if (amapApi) return amapApi |
| | | const key = import.meta.env.VITE_AMAP_KEY?.trim() |
| | | const securityJsCode = import.meta.env.VITE_AMAP_SECURITY_CODE?.trim() |
| | | if (!key || !securityJsCode) throw new Error('AMap credentials are not configured') |
| | | ;(window as any)._AMapSecurityConfig = { securityJsCode } |
| | | amapApi = await loadAMapApi({ |
| | | key, |
| | | version: '2.0', |
| | | plugins: ['AMap.PlaceSearch', 'AMap.Scale', 'AMap.ToolBar'], |
| | | }) |
| | | return amapApi |
| | | } |
| | | |
| | | interface ParkingAddress { |
| | | name: string |
| | |
| | | } |
| | | |
| | | function selectedCoordinates() { |
| | | const longitude = Number(pendingLongitude.value) |
| | | const latitude = Number(pendingLatitude.value) |
| | | const longitudeText = pendingLongitude.value.trim() |
| | | const latitudeText = pendingLatitude.value.trim() |
| | | if (!longitudeText || !latitudeText) return undefined |
| | | const longitude = Number(longitudeText) |
| | | const latitude = Number(latitudeText) |
| | | return Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined |
| | | } |
| | | |
| | |
| | | pendingLongitude.value = longitude.toFixed(6) |
| | | pendingLatitude.value = latitude.toFixed(6) |
| | | if (!locationMap) return |
| | | if (!locationMarker) locationMarker = L.marker([latitude, longitude], { icon: mapMarkerIcon }).addTo(locationMap) |
| | | else locationMarker.setLatLng([latitude, longitude]) |
| | | if (center) locationMap.setView([latitude, longitude], Math.max(locationMap.getZoom(), 16)) |
| | | const position = [longitude, latitude] |
| | | if (!locationMarker) { |
| | | locationMarker = new amapApi.Marker({ position, anchor: 'bottom-center' }) |
| | | locationMap.add(locationMarker) |
| | | } else { |
| | | locationMarker.setPosition(position) |
| | | } |
| | | if (center) locationMap.setZoomAndCenter(Math.max(locationMap.getZoom(), 16), position) |
| | | } |
| | | |
| | | async function initializeLocationMap() { |
| | | await nextTick() |
| | | if (!mapElement.value || locationMap) return |
| | | const initial = selectedCoordinates() || { longitude: 101.718637, latitude: 26.582347 } |
| | | locationMap = L.map(mapElement.value, { zoomControl: true }).setView([initial.latitude, initial.longitude], selectedCoordinates() ? 16 : 13) |
| | | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(locationMap) |
| | | locationMap.on('click', (event) => setMapMarker(event.latlng.lat, event.latlng.lng, false)) |
| | | if (selectedCoordinates()) setMapMarker(initial.latitude, initial.longitude, false) |
| | | window.setTimeout(() => locationMap?.invalidateSize(), 120) |
| | | try { |
| | | const AMap = await loadAMap() |
| | | const selected = selectedCoordinates() |
| | | const initial = selected || { longitude: 101.718637, latitude: 26.582347 } |
| | | locationMap = new AMap.Map(mapElement.value, { |
| | | viewMode: '2D', |
| | | zoom: selected ? 16 : 13, |
| | | center: [initial.longitude, initial.latitude], |
| | | resizeEnable: true, |
| | | }) |
| | | locationMap.addControl(new AMap.Scale()) |
| | | locationMap.addControl(new AMap.ToolBar({ position: { right: '10px', top: '10px' } })) |
| | | locationMap.on('click', (event: any) => setMapMarker(event.lnglat.getLat(), event.lnglat.getLng(), false)) |
| | | if (selected) setMapMarker(initial.latitude, initial.longitude, false) |
| | | window.setTimeout(() => locationMap?.resize(), 120) |
| | | } catch (error) { |
| | | console.error('Failed to initialize AMap', error) |
| | | ElMessage.error('高德地图加载失败,请检查地图配置或网络后重试') |
| | | } |
| | | } |
| | | |
| | | async function searchMapLocation() { |
| | |
| | | if (!keyword) { ElMessage.warning('请输入地址或地点关键词'); return } |
| | | locationSearching.value = true |
| | | try { |
| | | const response = await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=zh-CN&q=${encodeURIComponent(keyword)}`) |
| | | if (!response.ok) throw new Error('search failed') |
| | | const records = await response.json() as Array<{ display_name: string; lon: string; lat: string }> |
| | | locationSearchResults.value = records.map((item) => ({ name: item.display_name, longitude: Number(item.lon), latitude: Number(item.lat) })).filter((item) => Number.isFinite(item.longitude) && Number.isFinite(item.latitude)) |
| | | const AMap = await loadAMap() |
| | | const placeSearch = new AMap.PlaceSearch({ city: '攀枝花', citylimit: false, pageSize: 6, pageIndex: 1, extensions: 'base' }) |
| | | const records = await new Promise<any[]>((resolve, reject) => { |
| | | placeSearch.search(keyword, (status: string, result: any) => { |
| | | if (status === 'complete') resolve(result?.poiList?.pois || []) |
| | | else if (status === 'no_data') resolve([]) |
| | | else reject(new Error(result?.info || 'place search failed')) |
| | | }) |
| | | }) |
| | | locationSearchResults.value = records.map((item) => { |
| | | const longitude = Number(item.location?.getLng?.() ?? item.location?.lng) |
| | | const latitude = Number(item.location?.getLat?.() ?? item.location?.lat) |
| | | const address = [item.name, item.address].filter(Boolean).join(' - ') |
| | | return { name: address, longitude, latitude } |
| | | }).filter((item) => Number.isFinite(item.longitude) && Number.isFinite(item.latitude)) |
| | | if (!locationSearchResults.value.length) ElMessage.info('未找到匹配地点,请调整关键词或直接点击地图标点') |
| | | } catch { |
| | | } catch (error) { |
| | | console.error('Failed to search AMap places', error) |
| | | ElMessage.error('地点搜索失败,请检查网络后重试') |
| | | } finally { locationSearching.value = false } |
| | | } |
| | |
| | | } |
| | | |
| | | function closeLocationMap() { |
| | | locationMap?.remove() |
| | | locationMap?.destroy() |
| | | locationMap = undefined |
| | | locationMarker = undefined |
| | | locationSearchResults.value = [] |
| | |
| | | async function saveProduct() { |
| | | const valid = await productFormRef.value?.validate().catch(() => false) |
| | | if (!valid) return |
| | | if (!isHealthCheckMerchant.value && !isHotelMerchant.value) { |
| | | if (productRights.some((item) => item.free && !validFreeLimitCount(item.limitCount))) { |
| | | ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数') |
| | | return |
| | | } |
| | | if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) { |
| | | return |
| | | } |
| | | } else { |
| | | if (isHealthCheckMerchant.value) { |
| | | if (!productValidPeriodRange.value || productValidPeriodRange.value.length !== 2) { |
| | | ElMessage.warning('请选择可预约时间') |
| | | return |
| | |
| | | ElMessage.warning('请完善检查项目信息(名称、意义、适用性别均不能为空)') |
| | | return |
| | | } |
| | | } |
| | | } else if (!isHotelMerchant.value) { |
| | | if (productRights.some((item) => item.free && !validFreeLimitCount(item.limitCount))) { |
| | | ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数') |
| | | return |
| | | } |
| | | if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) { |
| | | return |
| | | } |
| | | } |
| | | productSaving.value = true |
| | |
| | | availableTime: rangeText(productAvailableTimeRange.value), |
| | | }, |
| | | productImages: [...productImages.value], |
| | | rightsConfig: productRights.map((item) => ({ ...item })), |
| | | rightsConfig: isHealthCheckMerchant.value || isHotelMerchant.value |
| | | ? [] |
| | | : productRights.map((item) => ({ ...item })), |
| | | } |
| | | if (editingProductId.value) { |
| | | await updateProduct(editingProductId.value, payload) |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div v-if="isParkingMerchant || isChildcareMerchant" class="merchant-rights-detail-row consume-desc-row"> |
| | | <div v-if="isChildcareMerchant" class="merchant-rights-detail-row consume-desc-row"> |
| | | <label>到店消费使用描述</label> |
| | | <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" /> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <div v-if="isParkingMerchant" class="merchant-rights-parking-address-panel" style="padding: 0 100px;"> |
| | | <div class="parking-address-header">停车场地址</div> |
| | | <div class="parking-address-table-wrapper"> |
| | | <el-table :data="parkingAddresses" border> |
| | | <el-table-column label="名称"> |
| | | <template #default="{ row }"><el-input v-model="row.name" placeholder="请输入" /></template> |
| | | </el-table-column> |
| | | <el-table-column label="地址"> |
| | | <template #default="{ row }"><el-input v-model="row.address" placeholder="请输入" /></template> |
| | | </el-table-column> |
| | | <el-table-column label="定位标点" width="120" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button type="primary" size="small" @click="markParkingLocation(row)">{{ row.latitude ? '已标点' : '未标点' }}</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="100" fixed="right" align="center"> |
| | | <template #default="{ $index }"> |
| | | <el-button link type="danger" @click="removeParkingAddress($index)">删除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div style="text-align: center; margin-top: 16px;"> |
| | | <el-button plain @click="addParkingAddress">新增</el-button> |
| | | </div> |
| | | <div v-if="isParkingMerchant" class="merchant-rights-detail-row consume-desc-row"> |
| | | <label>到店消费使用描述</label> |
| | | <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" /> |
| | | </div> |
| | | </div> |
| | | |
| | |
| | | </div> |
| | | </section> |
| | | |
| | | <section v-if="isParkingMerchant" class="merchant-parking-address-panel"> |
| | | <h3 class="merchant-parking-address-title">停车场地址</h3> |
| | | <el-table :data="parkingAddresses" border> |
| | | <el-table-column label="名称"> |
| | | <template #default="{ row }"><el-input v-model="row.name" placeholder="请输入" /></template> |
| | | </el-table-column> |
| | | <el-table-column label="地址"> |
| | | <template #default="{ row }"><el-input v-model="row.address" placeholder="请输入" /></template> |
| | | </el-table-column> |
| | | <el-table-column label="定位标点" width="120" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button type="primary" size="small" @click="markParkingLocation(row)">{{ row.latitude ? '已标点' : '未标点' }}</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="100" fixed="right" align="center"> |
| | | <template #default="{ $index }"> |
| | | <el-button link type="danger" @click="removeParkingAddress($index)">删除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="merchant-parking-address-actions"> |
| | | <el-button plain @click="addParkingAddress">新增</el-button> |
| | | </div> |
| | | </section> |
| | | |
| | | <section v-if="isChildcareMerchant" class="merchant-school-address-panel"> |
| | | <h3 class="merchant-school-address-title">学校地址</h3> |
| | | <el-table :data="schoolAddresses" border> |