SunnyFarm/frontend/src/components/MapAddressSelector.vue
2025-09-30 06:24:30 +08:00

825 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="map-address-selector">
<!-- 地图容器 -->
<div id="amap-container" class="map-container">
<div class="search-tip" v-if="showSearchTip">
<span>{{ searchTipText }}</span>
<el-icon class="close-icon" @click="showSearchTip = false"><Close /></el-icon>
</div>
<!-- 地图中心点标记 -->
<div class="center-marker">
<el-icon class="location-icon"><Location /></el-icon>
</div>
<!-- 获取我的位置按钮 -->
<div class="location-btn" @click="getCurrentLocation" title="获取我的位置">
<el-icon><Aim /></el-icon>
</div>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<div class="search-input-wrapper">
<el-icon class="search-icon"><Search /></el-icon>
<el-input
id="search-input"
v-model="searchKeyword"
placeholder="搜索地址,更快填写"
@input="onSearchInput"
@keyup.enter="searchAddress"
class="search-input"
clearable
/>
<el-button
type="text"
class="smart-paste-btn"
@click="smartPaste"
title="智能粘贴"
>
<el-icon><MagicStick /></el-icon>
智能粘贴
</el-button>
</div>
<!-- 搜索结果列表 -->
<div v-if="searchResults.length > 0" class="search-results">
<div
v-for="(result, index) in searchResults"
:key="index"
class="search-result-item"
@click="selectSearchResult(result)"
>
<div class="result-main">
<div class="result-name">{{ result.name }}</div>
<div class="result-address">{{ result.district }} {{ result.address }}</div>
</div>
<div class="result-distance" v-if="result.distance">
{{ formatDistance(result.distance) }}
</div>
</div>
</div>
</div>
<!-- 当前选中的地址信息 -->
<div class="selected-address" v-if="selectedAddress">
<div class="address-info">
<div class="address-main">
<span class="address-name">{{ selectedAddress.name || '选中位置' }}</span>
<span class="address-detail">{{ selectedAddress.formattedAddress }}</span>
</div>
<el-button type="primary" @click="confirmAddress" class="use-btn">
使用
</el-button>
</div>
</div>
<!-- 地址详细信息表单 -->
<div class="address-form-section">
<!-- 所在地区选择 -->
<div class="form-item">
<div class="form-label">
<el-icon class="required-icon"><Star /></el-icon>
所在地区
</div>
<div class="region-display">
{{ regionText }}
<el-switch
v-model="useDefaultAddress"
class="default-switch"
active-text="默认地址"
inactive-text=""
/>
</div>
</div>
<!-- 详细地址 -->
<div class="form-item">
<div class="form-label">
<el-icon class="required-icon"><Star /></el-icon>
详细地址与门牌号
</div>
<el-input
v-model="detailAddress"
placeholder="如2号楼1901室"
class="detail-input"
/>
<el-button
type="text"
class="import-btn"
@click="importExistingAddress"
>
导入已有地址
</el-button>
</div>
<!-- 联系人信息 -->
<div class="form-item">
<div class="form-label">
<el-icon class="required-icon"><Star /></el-icon>
收货人名字
</div>
<el-input
v-model="consigneeName"
placeholder="收货人姓名"
class="name-input"
/>
</div>
<div class="form-item">
<div class="form-label">
<el-icon class="required-icon"><Star /></el-icon>
手机号
</div>
<div class="phone-input-wrapper">
<el-select v-model="phonePrefix" class="phone-prefix">
<el-option label="+86" value="+86" />
</el-select>
<el-input
v-model="phoneNumber"
placeholder="手机号"
class="phone-input"
/>
</div>
</div>
<!-- 地址标签 -->
<div class="form-item">
<div class="form-label">地址标签</div>
<div class="address-tags">
<div
v-for="tag in addressTags"
:key="tag.value"
class="tag-item"
:class="{ active: selectedTag === tag.value }"
@click="selectTag(tag.value)"
>
<el-icon>
<component :is="tag.icon" />
</el-icon>
<span>{{ tag.label }}</span>
</div>
</div>
</div>
</div>
<!-- 保存按钮 -->
<div class="save-section">
<el-button
type="primary"
class="save-btn"
@click="saveAddress"
:loading="saving"
>
保存地址
</el-button>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as addressAPI from '../api/address'
import {
Close, Location, Aim, Search, MagicStick, Star,
House, OfficeBuilding, School, UserFilled, User, Setting
} from '@element-plus/icons-vue'
export default {
name: 'MapAddressSelector',
components: {
Close, Location, Aim, Search, MagicStick, Star,
House, OfficeBuilding, School, UserFilled, User, Setting
},
props: {
modelValue: {
type: Object,
default: () => ({})
}
},
emits: ['update:modelValue', 'save', 'cancel'],
setup(props, { emit }) {
const map = ref(null);
const marker = ref(null);
const geolocation = ref(null);
const geocoder = ref(null);
const searchKeyword = ref('');
const searchResults = ref([]);
const selectedAddress = ref(null);
const showSearchTip = ref(true);
const saving = ref(false);
// 表单数据
const detailAddress = ref('');
const consigneeName = ref('');
const phoneNumber = ref('');
const phonePrefix = ref('+86');
const useDefaultAddress = ref(false);
const selectedTag = ref('家');
// 地址标签选项
const addressTags = ref([
{ label: '家', value: '家', icon: 'House' },
{ label: '公司', value: '公司', icon: 'OfficeBuilding' },
{ label: '学校', value: '学校', icon: 'School' },
{ label: '父母', value: '父母', icon: 'UserFilled' },
{ label: '朋友', value: '朋友', icon: 'User' },
{ label: '自定义', value: '自定义', icon: 'Setting' }
]);
const searchTipText = computed(() => {
return '找不到地址?试试搜索吧';
});
const regionText = computed(() => {
if (selectedAddress.value) {
const addr = selectedAddress.value;
return `${addr.province || ''} ${addr.city || ''} ${addr.district || ''}`.trim();
}
return '请在地图上选择位置';
});
// 初始化地图
const initMap = () => {
if (!window.AMap) {
ElMessage.error('地图API加载失败请刷新页面重试');
return;
}
map.value = new AMap.Map('amap-container', {
zoom: 15,
center: [116.397428, 39.90923],
viewMode: '3D',
showLabel: true,
mapStyle: 'amap://styles/normal'
});
// 初始化地理编码
geocoder.value = new AMap.Geocoder({
extensions: 'all'
});
// 初始化定位
geolocation.value = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
convert: true,
showButton: false,
buttonPosition: 'LB',
showMarker: false,
showCircle: false,
panToLocation: true,
zoomToAccuracy: true
});
// 监听地图移动
map.value.on('moveend', onMapMoveEnd);
map.value.on('zoomend', onMapMoveEnd);
// 自动获取当前位置
getCurrentLocation();
};
// 地图移动结束事件
const onMapMoveEnd = () => {
const center = map.value.getCenter();
geocodeByLocation(center);
};
// 根据坐标获取地址信息
const geocodeByLocation = (location) => {
if (!geocoder.value) return;
geocoder.value.getAddress([location.lng, location.lat], (status, result) => {
if (status === 'complete' && result.regeocode) {
const regeocode = result.regeocode;
selectedAddress.value = {
lng: location.lng,
lat: location.lat,
formattedAddress: regeocode.formattedAddress,
province: regeocode.addressComponent.province,
city: regeocode.addressComponent.city,
district: regeocode.addressComponent.district,
township: regeocode.addressComponent.township,
name: regeocode.pois.length > 0 ? regeocode.pois[0].name : ''
};
}
});
};
// 获取当前位置
const getCurrentLocation = () => {
if (!geolocation.value) return;
geolocation.value.getCurrentPosition((status, result) => {
if (status === 'complete') {
const position = result.position;
map.value.setCenter([position.lng, position.lat]);
map.value.setZoom(16);
geocodeByLocation(position);
ElMessage.success('定位成功');
} else {
ElMessage.warning('定位失败,请手动选择位置');
}
});
};
// 搜索输入事件
const onSearchInput = () => {
if (searchKeyword.value.trim()) {
searchAddress();
} else {
searchResults.value = [];
}
};
// 搜索地址 - 调用后端API
const searchAddress = async () => {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
return;
}
try {
const result = await addressAPI.searchPOI(searchKeyword.value, '全国');
console.log('🔍 搜索结果:', result);
if (result && result.length > 0) {
searchResults.value = result.map(poi => ({
id: poi.id,
name: poi.name,
address: poi.address,
district: poi.district,
location: { lng: poi.lng, lat: poi.lat },
distance: 0
}));
console.log('🎯 处理后的搜索结果:', searchResults.value);
} else {
searchResults.value = [];
ElMessage.info('未找到相关地址');
}
} catch (error) {
console.error('❌ 搜索错误:', error);
searchResults.value = [];
ElMessage.error('搜索失败,请稍后重试');
}
};
// 选择搜索结果
const selectSearchResult = (result) => {
const location = result.location;
map.value.setCenter([location.lng, location.lat]);
map.value.setZoom(16);
selectedAddress.value = {
lng: location.lng,
lat: location.lat,
formattedAddress: result.district + result.address,
name: result.name
};
searchResults.value = [];
searchKeyword.value = result.name;
// 重新地理编码获取详细信息
geocodeByLocation(location);
};
// 格式化距离
const formatDistance = (distance) => {
if (distance < 1000) {
return Math.round(distance) + 'm';
} else {
return (distance / 1000).toFixed(1) + 'km';
}
};
// 确认地址
const confirmAddress = () => {
if (selectedAddress.value) {
if (selectedAddress.value.name && !detailAddress.value) {
detailAddress.value = selectedAddress.value.name;
}
}
};
// 智能粘贴
const smartPaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
searchKeyword.value = text;
searchAddress();
}
} catch (error) {
ElMessage.warning('无法读取剪贴板内容');
}
};
// 导入已有地址
const importExistingAddress = () => {
emit('import-existing');
};
// 选择地址标签
const selectTag = (tag) => {
selectedTag.value = tag;
};
// 保存地址
const saveAddress = () => {
if (!selectedAddress.value) {
ElMessage.error('请在地图上选择地址');
return;
}
if (!consigneeName.value.trim()) {
ElMessage.error('请输入收货人姓名');
return;
}
if (!phoneNumber.value.trim()) {
ElMessage.error('请输入手机号');
return;
}
if (!/^1[3-9]\d{9}$/.test(phoneNumber.value)) {
ElMessage.error('请输入正确的手机号');
return;
}
if (!detailAddress.value.trim()) {
ElMessage.error('请输入详细地址');
return;
}
const addressData = {
consignee: consigneeName.value,
phone: phoneNumber.value,
province: selectedAddress.value.province,
city: selectedAddress.value.city,
district: selectedAddress.value.district,
address: detailAddress.value,
isDefault: useDefaultAddress.value,
tag: selectedTag.value,
lng: selectedAddress.value.lng,
lat: selectedAddress.value.lat,
formattedAddress: selectedAddress.value.formattedAddress
};
emit('save', addressData);
};
// 初始化表单数据
const initFormData = () => {
if (props.modelValue) {
const data = props.modelValue;
consigneeName.value = data.consignee || '';
phoneNumber.value = data.phone || '';
detailAddress.value = data.address || '';
useDefaultAddress.value = data.isDefault || false;
selectedTag.value = data.tag || '家';
if (data.lng && data.lat && map.value) {
map.value.setCenter([data.lng, data.lat]);
selectedAddress.value = {
lng: data.lng,
lat: data.lat,
formattedAddress: `${data.province} ${data.city} ${data.district} ${data.address}`,
province: data.province,
city: data.city,
district: data.district
};
}
}
};
watch(() => props.modelValue, initFormData, { immediate: true });
onMounted(() => {
setTimeout(initMap, 100);
});
onUnmounted(() => {
if (map.value) {
map.value.destroy();
}
});
return {
searchKeyword,
searchResults,
selectedAddress,
showSearchTip,
searchTipText,
saving,
detailAddress,
consigneeName,
phoneNumber,
phonePrefix,
useDefaultAddress,
selectedTag,
addressTags,
regionText,
getCurrentLocation,
onSearchInput,
searchAddress,
selectSearchResult,
formatDistance,
confirmAddress,
smartPaste,
importExistingAddress,
selectTag,
saveAddress
};
}
}
</script>
<style scoped>
.map-address-selector {
height: 100%;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.map-container {
height: 200px;
position: relative;
background: #e8e8e8;
}
.search-tip {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
}
.close-icon {
cursor: pointer;
font-size: 16px;
}
.center-marker {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
z-index: 1000;
color: #ff4757;
font-size: 24px;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.3));
}
.location-btn {
position: absolute;
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
z-index: 1000;
color: #4CAF50;
font-size: 18px;
}
.location-btn:hover {
background: #f5f5f5;
}
.search-section {
background: white;
padding: 16px;
}
.search-input-wrapper {
display: flex;
align-items: center;
background: #f8f9fa;
border-radius: 8px;
padding: 8px 12px;
gap: 8px;
}
.search-icon {
color: #666;
font-size: 18px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
}
.search-input :deep(.el-input__wrapper) {
background: transparent;
box-shadow: none;
}
.smart-paste-btn {
color: #4CAF50;
font-size: 12px;
padding: 0;
margin: 0;
}
.search-results {
margin-top: 12px;
max-height: 200px;
overflow-y: auto;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.search-result-item:hover {
background: #f8f9fa;
}
.result-main {
flex: 1;
}
.result-name {
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.result-address {
font-size: 12px;
color: #666;
}
.result-distance {
font-size: 12px;
color: #999;
}
.selected-address {
background: white;
border-top: 1px solid #e8e8e8;
padding: 16px;
}
.address-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.address-main {
flex: 1;
}
.address-name {
font-weight: 500;
color: #333;
display: block;
margin-bottom: 4px;
}
.address-detail {
font-size: 12px;
color: #666;
}
.use-btn {
background: #ff4757;
border: none;
border-radius: 20px;
padding: 8px 20px;
font-size: 14px;
}
.address-form-section {
flex: 1;
background: white;
padding: 16px;
overflow-y: auto;
max-height: calc(85vh - 200px - 200px);
}
.form-item {
margin-bottom: 20px;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #333;
margin-bottom: 8px;
}
.required-icon {
color: #ff4757;
font-size: 12px;
}
.region-display {
display: flex;
align-items: center;
justify-content: space-between;
color: #666;
font-size: 14px;
}
.detail-input,
.name-input {
width: 100%;
}
.import-btn {
color: #4CAF50;
font-size: 14px;
margin-top: 8px;
padding: 0;
}
.phone-input-wrapper {
display: flex;
gap: 8px;
}
.phone-prefix {
width: 80px;
}
.phone-input {
flex: 1;
}
.address-tags {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.tag-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.tag-item:hover {
border-color: #4CAF50;
}
.tag-item.active {
border-color: #4CAF50;
background: #f8fff8;
color: #4CAF50;
}
.save-section {
background: white;
padding: 12px 16px;
border-top: 1px solid #e8e8e8;
}
.save-btn {
width: 100%;
height: 40px;
background: #ff6b35;
border: none;
border-radius: 22px;
font-size: 16px;
font-weight: 500;
}
</style>