feat:自提点

This commit is contained in:
dzc committed 2025-10-16 16:56:35 +08:00
1 parent 8dba6a0894
commit cf8aa56c9c
13 files changed
+1767 -15

No files matched your search

+486
View File
@@ -0,0 +1,486 @@
<template>
<el-dialog v-model="state.visibleMapDialog" title="地图位置选择器" width="80%" :before-close="handleClose"
class="map-picker-dialog">
<div class="map-picker-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="state.searchKeyword" placeholder="请输入地址或POI名称进行搜索" class="search-input"
@keyup.enter="handleSearch">
<template #append>
<el-button @click="handleSearch" :loading="state.searching">
<el-icon>
<Search />
</el-icon>
</el-button>
</template>
</el-input>
</div>
<!-- 地图容器 -->
<div class="map-container">
<tmap-map ref="mapRef" style="width: 100%; height: 500px;" mapKey="CGABZ-3MH66-6VGST-MEMS3-K6U3V-DGBKA"
:center="map.center" :zoom="map.zoom" :doubleClickZoom="map.doubleClickZoom"
@click="handleMapClick">
<!-- 标记点 -->
<tmap-marker v-if="state.selectedLocation" :position="state.selectedLocation" :draggable="true"
@dragend="handleMarkerDragEnd" />
</tmap-map>
<!-- 地图中心十字标 -->
<div class="map-crosshair">
<div class="crosshair-horizontal"></div>
<div class="crosshair-vertical"></div>
</div>
</div>
<!-- 信息面板 -->
<div class="info-panel">
<div class="location-info">
<div class="info-row">
<span class="label">选中地址:</span>
<span class="value">{{ state.selectedAddress || '请在地图上点击选择位置' }}</span>
</div>
<div class="info-row">
<span class="label">经纬度:</span>
<span class="value">
{{ state.selectedLocation ? `${state.selectedLocation.lng}, ${state.selectedLocation.lat}` :
'未选择' }}
</span>
</div>
</div>
<!-- 搜索结果列表 -->
<div v-if="state.searchResults.length > 0" class="search-results">
<div class="results-title">搜索结果</div>
<div class="results-list">
<div v-for="(item, index) in state.searchResults" :key="index" class="result-item"
@click="selectSearchResult(item)">
<div class="result-title">{{ item.title }}</div>
<div class="result-address">{{ item.address }}</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!state.selectedLocation">
确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="map-picker-dialog">
import { reactive, ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { TencentMapAPI } from '../../utils/jsonp'
// 定义事件
const emit = defineEmits(['confirm', 'close'])
// 响应式状态
const state = reactive<any>({
visibleMapDialog: false,
searchKeyword: '',
searching: false,
selectedLocation: null,
selectedAddress: '',
searchResults: []
})
// 地图配置
const map = reactive<any>({
// 默认县府
center: { lat: 29.453297, lng: 119.89259 },
zoom: 15,
doubleClickZoom: true,
})
// 地图引用
const mapRef = ref()
// 腾讯地图API密钥 <mcreference link="https://lbs.qq.com/getPoint/#J" index="0">0</mcreference>
const TENCENT_MAP_KEY = "4INBZ-UIUCZ-ILHXD-7P2GM-6C2LZ-S7BNI"
// 创建腾讯地图API实例 (启用调试模式)
const mapAPI = new TencentMapAPI(TENCENT_MAP_KEY, true)
onMounted(() => {
// 初始化地图相关功能
})
// 打开对话框
const open = (initialLocation?: any) => {
state.visibleMapDialog = true
resetState()
// 如果有初始位置,设置为当前选中位置
if (initialLocation) {
state.selectedLocation = { ...initialLocation }
map.center = initialLocation
state.selectedAddress = '正在获取地址...'
reverseGeocode(initialLocation)
}
// 等待DOM更新后初始化地图
nextTick(() => {
initMap(initialLocation)
})
}
// 关闭对话框
const handleClose = () => {
state.visibleMapDialog = false
resetState()
emit('close')
}
// 重置状态
const resetState = () => {
state.searchKeyword = ''
state.searching = false
state.selectedLocation = null
state.selectedAddress = ''
state.searchResults = []
}
// 地图点击事件
const handleMapClick = (event: any) => {
const { lat, lng } = event.latLng
state.selectedLocation = { lat, lng }
// 逆地址解析
reverseGeocode({ lat, lng })
}
// 标记拖拽结束事件
const handleMarkerDragEnd = (event: any) => {
const { lat, lng } = event.latLng
state.selectedLocation = { lat, lng }
// 逆地址解析
reverseGeocode({ lat, lng })
}
// 搜索地址
const handleSearch = async () => {
if (!state.searchKeyword.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
state.searching = true
try {
const results = await searchLocation(state.searchKeyword)
state.searchResults = results
if (results.length === 0) {
ElMessage.info('未找到相关地址')
}
} catch (error) {
console.error('搜索失败:', error)
ElMessage.error('搜索失败,请重试')
} finally {
state.searching = false
}
}
// 选择搜索结果
const selectSearchResult = (item: any) => {
state.selectedLocation = { lat: item.location.lat, lng: item.location.lng }
state.selectedAddress = item.address
map.center = state.selectedLocation
// 清空搜索结果
state.searchResults = []
state.searchKeyword = item.title
}
// 确认选择
const handleConfirm = () => {
if (!state.selectedLocation) {
ElMessage.warning('请先选择一个位置')
return
}
const result = {
location: state.selectedLocation,
address: state.selectedAddress,
coordinates: `${state.selectedLocation.lng},${state.selectedLocation.lat}`
}
emit('confirm', result)
handleClose()
}
// 逆地址解析
const reverseGeocode = async (location: { lat: number, lng: number }) => {
try {
const data = await mapAPI.reverseGeocode(location.lat, location.lng)
if (data.status === 0) {
state.selectedAddress = data.result.address
} else {
console.error('逆地址解析失败:', data.message)
state.selectedAddress = `纬度:${location.lat}, 经度:${location.lng}`
}
} catch (error) {
console.error('逆地址解析错误:', error)
state.selectedAddress = `纬度:${location.lat}, 经度:${location.lng}`
}
}
// 地址搜索
const searchLocation = async (keyword: string) => {
try {
const data = await mapAPI.searchLocation(keyword)
if (data.status === 0) {
return data.data.map((item: any) => ({
title: item.title,
address: item.address,
location: item.location
}))
} else {
console.error('地址搜索失败:', data.message)
return []
}
} catch (error) {
console.error('地址搜索错误:', error)
return []
}
}
// 初始化地图
const initMap = (initialLocation?: any) => {
// 这里应该初始化腾讯地图
// 由于需要腾讯地图SDK,这里提供一个基础的实现框架
console.log('初始化地图', initialLocation)
// 如果有初始位置,设置地图中心
if (initialLocation) {
// 设置地图中心到初始位置
// map.setCenter(new TMap.LatLng(initialLocation.lat, initialLocation.lng))
}
}
// 暴露方法
defineExpose({
open
})
</script>
<style scoped>
.map-picker-dialog {
.el-dialog__body {
padding: 0;
}
}
.map-picker-container {
display: flex;
flex-direction: column;
height: 600px;
}
.search-bar {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
background: #fff;
}
.search-input {
width: 100%;
}
.map-container {
position: relative;
flex: 1;
overflow: hidden;
}
.map-crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 1000;
}
.crosshair-horizontal,
.crosshair-vertical {
position: absolute;
background: #ff4757;
}
.crosshair-horizontal {
width: 20px;
height: 2px;
top: -1px;
left: -10px;
}
.crosshair-vertical {
width: 2px;
height: 20px;
top: -10px;
left: -1px;
}
.info-panel {
padding: 16px;
border-top: 1px solid #e4e7ed;
background: #f8f9fa;
max-height: 200px;
overflow-y: auto;
}
.location-info {
margin-bottom: 16px;
}
.info-row {
display: flex;
margin-bottom: 8px;
font-size: 14px;
}
.label {
color: #606266;
width: 80px;
flex-shrink: 0;
}
.value {
color: #303133;
flex: 1;
word-break: break-all;
}
.search-results {
border-top: 1px solid #e4e7ed;
padding-top: 16px;
}
.results-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
}
.results-list {
max-height: 150px;
overflow-y: auto;
}
.result-item {
padding: 8px 12px;
border: 1px solid #e4e7ed;
border-radius: 4px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.result-item:hover {
border-color: #409eff;
background: #f0f9ff;
}
.result-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.result-address {
font-size: 12px;
color: #909399;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.map-picker-container {
height: 500px;
}
.info-panel {
max-height: 150px;
}
.info-row {
flex-direction: column;
}
.label {
width: auto;
margin-bottom: 4px;
}
}
/* 地图标记样式优化 */
:deep(.tmap-marker) {
animation: bounce 0.6s ease-in-out;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
/* 搜索结果滚动条样式 */
.results-list::-webkit-scrollbar,
.info-panel::-webkit-scrollbar {
width: 6px;
}
.results-list::-webkit-scrollbar-track,
.info-panel::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.results-list::-webkit-scrollbar-thumb,
.info-panel::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.results-list::-webkit-scrollbar-thumb:hover,
.info-panel::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="map-example-container">
<el-card header="地图位置选择器示例">
<div class="example-section">
<h3>基础用法</h3>
<map-select v-model="basicLocation" placeholder="请选择位置" @change="handleBasicChange" />
<div v-if="basicLocation" class="result-display">
<p><strong>选中地址:</strong>{{ basicLocation.address }}</p>
<p><strong>经纬度:</strong>{{ basicLocation.coordinates }}</p>
</div>
</div>
<el-divider />
<div class="example-section">
<h3>只返回坐标</h3>
<map-select v-model="coordinatesOnly" placeholder="选择位置(只返回坐标)" return-format="coordinates"
@change="handleCoordinatesChange" />
<div v-if="coordinatesOnly" class="result-display">
<p><strong>坐标:</strong>{{ coordinatesOnly }}</p>
</div>
</div>
<el-divider />
<div class="example-section">
<h3>只返回地址</h3>
<map-select v-model="addressOnly" placeholder="选择位置(只返回地址)" return-format="address"
@change="handleAddressChange" />
<div v-if="addressOnly" class="result-display">
<p><strong>地址:</strong>{{ addressOnly }}</p>
</div>
</div>
<el-divider />
<div class="example-section">
<h3>隐藏坐标信息</h3>
<map-select v-model="noCoordinates" placeholder="选择位置(隐藏坐标)" :show-coordinates="false"
@change="handleNoCoordinatesChange" />
<div v-if="noCoordinates" class="result-display">
<p><strong>选中地址:</strong>{{ noCoordinates.address }}</p>
</div>
</div>
<el-divider />
<div class="example-section">
<h3>禁用状态</h3>
<map-select v-model="disabledLocation" placeholder="禁用状态" disabled />
</div>
<el-divider />
<div class="example-section">
<h3>预设位置</h3>
<el-button @click="setPresetLocation">设置预设位置(天安门)</el-button>
<map-select v-model="presetLocation" placeholder="预设位置示例" @change="handlePresetChange" />
<div v-if="presetLocation" class="result-display">
<p><strong>选中地址:</strong>{{ presetLocation.address }}</p>
<p><strong>经纬度:</strong>{{ presetLocation.coordinates }}</p>
</div>
</div>
<el-divider />
<div class="example-section">
<h3>表单集成示例</h3>
<el-form :model="formData" label-width="120px">
<el-form-item label="商店名称">
<el-input v-model="formData.name" placeholder="请输入商店名称" />
</el-form-item>
<el-form-item label="商店位置">
<map-select v-model="formData.location" placeholder="请选择商店位置"
@change="handleFormLocationChange" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import MapSelect from './map-select.vue'
// 定义位置数据类型
interface LocationData {
address: string
location: { lat: number, lng: number }
coordinates: string
}
// 基础用法
const basicLocation = ref<LocationData | null>(null)
const handleBasicChange = (value: any) => {
console.log('基础用法选择结果:', value)
}
// 只返回坐标
const coordinatesOnly = ref<string | null>(null)
const handleCoordinatesChange = (value: any) => {
console.log('坐标结果:', value)
}
// 只返回地址
const addressOnly = ref<string | null>(null)
const handleAddressChange = (value: any) => {
console.log('地址结果:', value)
}
// 隐藏坐标信息
const noCoordinates = ref<LocationData | null>(null)
const handleNoCoordinatesChange = (value: any) => {
console.log('隐藏坐标结果:', value)
}
// 禁用状态
const disabledLocation = ref<LocationData>({
address: '北京市朝阳区某某街道',
location: { lat: 39.9042, lng: 116.4074 },
coordinates: '116.4074,39.9042'
})
// 预设位置
const presetLocation = ref<LocationData | null>(null)
const setPresetLocation = () => {
presetLocation.value = {
address: '北京市东城区天安门广场',
location: { lat: 39.9042, lng: 116.4074 },
coordinates: '116.4074,39.9042'
}
}
const handlePresetChange = (value: any) => {
console.log('预设位置结果:', value)
}
// 表单集成
const formData = reactive<{
name: string
location: LocationData | null
phone: string
}>({
name: '',
location: null,
phone: ''
})
const handleFormLocationChange = (value: any) => {
console.log('表单位置选择:', value)
}
const submitForm = () => {
if (!formData.name) {
ElMessage.warning('请输入商店名称')
return
}
if (!formData.location) {
ElMessage.warning('请选择商店位置')
return
}
if (!formData.phone) {
ElMessage.warning('请输入联系电话')
return
}
console.log('提交表单数据:', formData)
ElMessage.success('表单提交成功')
}
const resetForm = () => {
formData.name = ''
formData.location = null
formData.phone = ''
ElMessage.info('表单已重置')
}
</script>
<style scoped>
.map-example-container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.example-section {
margin-bottom: 24px;
}
.example-section h3 {
margin-bottom: 16px;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.result-display {
margin-top: 12px;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 14px;
}
.result-display p {
margin: 4px 0;
color: #606266;
}
.result-display strong {
color: #303133;
}
.el-divider {
margin: 32px 0;
}
.el-form {
max-width: 500px;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="map-select-container">
<el-input v-model="state.selectAddress" :placeholder="props.placeholder" :disabled="props.disabled"
:clearable="props.clearable" class="map-select-input" readonly @clear="handleClear">
<template #prepend v-if="state.selectedLocation">
<el-icon class="location-icon">
<Location />
</el-icon>
</template>
<template #append>
<el-button @click="handleMapClick" :disabled="props.disabled" class="map-button">
<el-icon>
<MapLocation />
</el-icon>
选择位置
</el-button>
</template>
</el-input>
<!-- 坐标信息显示 -->
<div v-if="state.selectedLocation && state.showCoordinates" class="coordinates-info">
<span class="coordinates-text">
经纬度: {{ state.selectedLocation.lng.toFixed(6) }}, {{ state.selectedLocation.lat.toFixed(6) }}
</span>
<el-button text size="small" @click="copyCoordinates" class="copy-btn">
<el-icon>
<CopyDocument />
</el-icon>
复制
</el-button>
</div>
<map-dialog ref="mapDialogRef" @confirm="handleLocationConfirm" @close="handleDialogClose" />
</div>
</template>
<script setup lang="ts" name="map-location-select">
import { reactive, ref, onMounted, watch, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { MapLocation, Location, CopyDocument } from '@element-plus/icons-vue'
const MapDialog = defineAsyncComponent(() => import('./map-dialog.vue'))
// 定义事件
const emit = defineEmits(['change', 'update:modelValue'])
// 定义属性
const props = withDefaults(
defineProps<{
modelValue?: any
placeholder?: string
disabled?: boolean
clearable?: boolean
showCoordinates?: boolean
returnFormat?: 'object' | 'coordinates' | 'address'
}>(),
{
placeholder: "请点击选择地图位置",
disabled: false,
clearable: true,
showCoordinates: true,
returnFormat: 'object'
}
)
// 响应式状态
const state = reactive<any>({
selectAddress: '',
selectedLocation: null,
selectedData: null
})
// 引用
const mapDialogRef = ref()
// 清空选择
const handleClear = () => {
state.selectAddress = ''
state.selectedLocation = null
state.selectedData = null
emit('update:modelValue', null)
emit('change', null)
}
// 打开地图选择器
const handleMapClick = () => {
if (props.disabled) return
// 如果已有位置,传递给对话框作为初始位置
mapDialogRef.value?.open(state.selectedLocation)
}
// 处理位置确认
const handleLocationConfirm = (result: any) => {
state.selectAddress = result.address
state.selectedLocation = result.location
state.selectedData = result
// 根据返回格式发送不同的值
let emitValue
switch (props.returnFormat) {
case 'coordinates':
emitValue = result.coordinates
break
case 'address':
emitValue = result.address
break
case 'object':
default:
emitValue = result
break
}
emit('update:modelValue', emitValue)
emit('change', emitValue)
ElMessage.success('位置选择成功')
}
// 处理对话框关闭
const handleDialogClose = () => {
// 对话框关闭时的处理
}
// 监听外部值变化
watch(() => props.modelValue, (newValue) => {
if (newValue) {
if (typeof newValue === 'object' && newValue.address) {
state.selectAddress = newValue.address
state.selectedLocation = newValue.location
state.selectedData = newValue
} else if (typeof newValue === 'string') {
state.selectAddress = newValue
}
} else {
handleClear()
}
}, { immediate: true })
onMounted(() => {
// 初始化
})
// 复制坐标
const copyCoordinates = async () => {
if (!state.selectedLocation) return
const coordinates = `${state.selectedLocation.lng},${state.selectedLocation.lat}`
try {
await navigator.clipboard.writeText(coordinates)
ElMessage.success('坐标已复制到剪贴板')
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = coordinates
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
ElMessage.success('坐标已复制到剪贴板')
}
}
// 暴露方法
defineExpose({
open: handleMapClick,
clear: handleClear,
getLocation: () => state.selectedLocation,
getAddress: () => state.selectAddress,
getData: () => state.selectedData
})
</script>
<style scoped>
.map-select-container {
width: 100%;
}
.map-select-input {
width: 100%;
}
.map-select-input :deep(.el-input__inner) {
cursor: pointer;
}
.location-icon {
color: #409eff;
}
.map-button {
border: none;
background: #409eff;
color: white;
padding: 0 16px;
display: flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
}
.map-button:hover {
background: #337ecc;
}
.map-button:disabled {
background: #c0c4cc;
cursor: not-allowed;
}
.coordinates-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 12px;
}
.coordinates-text {
color: #606266;
flex: 1;
}
.copy-btn {
padding: 4px 8px;
height: auto;
font-size: 12px;
}
.copy-btn:hover {
color: #409eff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.map-button {
padding: 0 12px;
font-size: 12px;
}
.coordinates-info {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.copy-btn {
align-self: flex-end;
}
}
</style>