shinnosuke
2026-08-18 99bfc2060840436d5ed06e5f37a2ce6ae0ab44e8
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1,8 +1,7 @@
<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'
@@ -995,9 +994,23 @@
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
@@ -1162,8 +1175,11 @@
}
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
}
@@ -1171,20 +1187,38 @@
  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: '&copy; 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() {
@@ -1192,12 +1226,24 @@
  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 }
}
@@ -1209,7 +1255,7 @@
}
function closeLocationMap() {
  locationMap?.remove()
  locationMap?.destroy()
  locationMap = undefined
  locationMarker = undefined
  locationSearchResults.value = []
@@ -1656,15 +1702,7 @@
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
@@ -1678,6 +1716,14 @@
        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
@@ -1696,7 +1742,9 @@
        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)
@@ -2512,37 +2560,15 @@
                </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>
@@ -2599,6 +2625,31 @@
        </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>