This commit is contained in:
2026-07-30 11:24:40 +08:00
parent 42da6fc74e
commit 0eb2835215
15 changed files with 899 additions and 509 deletions
+517 -228
View File
@@ -1,82 +1,101 @@
<template>
<div class="app">
<header class="header">
<h1>Pointly-Tel</h1>
<p class="subtitle">高德地图 POI 商户信息查询</p>
</header>
<!-- 地图全屏底层 -->
<PoiMap ref="mapRef" @marker-click="onMarkerClick" @map-click="onMapClick" />
<main class="main">
<div class="search-box">
<div class="form-row">
<input
v-model="keywords"
class="input"
placeholder="输入关键词(如:火锅店 咖啡厅"
@keyup.enter="doSearch"
/>
<input
v-model="city"
class="input city-input"
placeholder="城市(可选)"
@keyup.enter="doSearch"
/>
<button class="btn" :disabled="loading" @click="doSearch">
{{ loading ? '搜索中...' : '搜索' }}
</button>
</div>
<!-- 顶部搜索栏 -->
<div class="top-bar">
<div class="search-form">
<input
v-model="keywords"
class="input"
placeholder="关键词(选填,如:火锅店)"
@keyup.enter="doSearch"
/>
<select v-model="province" class="select province-select" @change="provinceChanged">
<option value="">所有省份</option>
<option v-for="p in provinces" :key="p" :value="p">{{ p }}</option>
</select>
<select v-model="city" class="select city-select" :disabled="!province">
<option value="">{{ province ? '所有城市' : '请先选省份' }}</option>
<option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="radius" class="select radius-select">
<option v-for="r in radiusOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<button class="btn" :disabled="loading" @click="doSearch">
{{ loading ? '搜索中...' : '搜索' }}
</button>
</div>
<!-- 已选中心点 -->
<div v-if="centerLat || centerLng" class="center-badge">
<span>中心点{{ centerLng.toFixed(4) }}, {{ centerLat.toFixed(4) }}</span>
<button class="clear-center" @click="clearCenter">×</button>
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="loading">
<!-- 右侧结果面板 -->
<div v-if="searched" class="side-panel" :class="{ 'has-results': results.length > 0 }">
<!-- 加载中 -->
<div v-if="loading" class="panel-loading">
<div class="spinner"></div>
<span>正在查询...</span>
</div>
<div v-if="!loading && searched && results.length === 0" class="empty">
未找到相关商户请尝试其他关键词
<!-- 错误 -->
<div v-else-if="error" class="panel-error">{{ error }}</div>
<!-- 空结果 -->
<div v-else-if="results.length === 0" class="panel-empty">
未找到相关商户<br /><small>请尝试其他关键词</small>
</div>
<div v-if="results.length > 0" class="results">
<div class="result-meta"> {{ total }} 条结果</div>
<div v-for="item in results" :key="item.poiId" class="card">
<div class="card-header">
<h3>{{ item.name }}</h3>
<span v-if="item.category" class="tag">{{ item.category.split('|').pop() }}</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="label">电话</span>
<a v-if="item.phone" :href="'tel:' + item.phone" class="phone">{{ item.phone }}</a>
<span v-else class="na">暂无</span>
</div>
<div class="info-row">
<span class="label">地址</span>
<span>{{ item.address || '暂无' }}</span>
</div>
<div class="info-row" v-if="item.businessArea">
<span class="label">商圈</span>
<span>{{ item.businessArea }}</span>
</div>
<!-- 结果列表 -->
<template v-else>
<div class="panel-header">
<span class="panel-title"> {{ total }} 条结果</span>
<span class="panel-page"> {{ page }}/{{ maxPage }} </span>
</div>
<div class="panel-list">
<div
v-for="item in results"
:key="item.poiId"
class="result-card"
:class="{ active: activePoi && activePoi.poiId === item.poiId }"
@click="focusItem(item)"
>
<div class="card-name">{{ item.name }}</div>
<div class="card-phone">{{ item.phone || '暂无电话' }}</div>
<div class="card-addr">{{ item.address }}</div>
</div>
</div>
<div v-if="total > results.length" class="pagination">
<button class="btn-outline" :disabled="page <= 1" @click="page--; doSearch()">上一页</button>
<span class="page-info"> {{ page }} / {{ maxPage }} {{ total }} </span>
<button class="btn-outline" :disabled="page >= maxPage" @click="page++; doSearch()">下一页</button>
<div class="panel-footer">
<button class="page-btn" :disabled="page <= 1" @click="page--; doSearch()">上一页</button>
<button class="page-btn" :disabled="page >= maxPage" @click="page++; doSearch()">下一页</button>
</div>
</div>
</main>
</template>
</div>
<!-- 操作提示 -->
<div class="hint-bar">
<span>点击地图设置搜索中心点</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import { searchPoi } from './api/poi.js'
import PoiMap from './components/PoiMap.vue'
const mapRef = ref(null)
const keywords = ref('')
const city = ref('')
const radius = ref(3000)
const centerLat = ref(0)
const centerLng = ref(0)
const results = ref([])
const total = ref(0)
const loading = ref(false)
@@ -85,22 +104,210 @@ const error = ref('')
const page = ref(1)
const offset = ref(20)
const maxPage = ref(1)
const activePoi = ref(null)
const province = ref('')
const provinces = [
'北京', '天津', '上海', '重庆',
'广东', '浙江', '江苏', '福建', '山东', '四川', '湖北', '湖南',
'河南', '河北', '安徽', '江西', '陕西', '山西', '辽宁', '吉林',
'黑龙江', '广西', '内蒙古', '西藏', '新疆', '宁夏', '青海',
'甘肃', '贵州', '云南', '海南',
]
const regions = {
'北京': ['北京'],
'天津': ['天津'],
'上海': ['上海'],
'重庆': ['重庆'],
'广东': ['广州', '深圳', '东莞', '佛山', '珠海', '中山', '惠州', '汕头', '江门', '湛江', '肇庆', '茂名', '揭阳', '梅州', '清远', '韶关', '阳江', '河源', '汕尾', '潮州', '云浮'],
'浙江': ['杭州', '宁波', '温州', '绍兴', '嘉兴', '金华', '台州', '湖州', '衢州', '舟山', '丽水'],
'江苏': ['南京', '苏州', '无锡', '常州', '南通', '徐州', '扬州', '盐城', '淮安', '连云港', '镇江', '泰州', '宿迁'],
'福建': ['福州', '厦门', '泉州', '漳州', '莆田', '宁德', '龙岩', '三明', '南平'],
'山东': ['济南', '青岛', '烟台', '潍坊', '临沂', '淄博', '济宁', '威海', '泰安', '德州', '聊城', '东营', '枣庄', '日照', '滨州', '菏泽'],
'四川': ['成都', '绵阳', '德阳', '宜宾', '南充', '泸州', '自贡', '乐山', '眉山', '达州', '内江', '遂宁', '广安', '攀枝花', '资阳', '广元', '雅安', '巴中'],
'湖北': ['武汉', '襄阳', '宜昌', '荆州', '黄冈', '十堰', '孝感', '荆门', '鄂州', '黄石', '咸宁', '随州'],
'湖南': ['长沙', '株洲', '湘潭', '衡阳', '岳阳', '常德', '益阳', '邵阳', '郴州', '怀化', '永州', '娄底', '湘西'],
'河南': ['郑州', '洛阳', '南阳', '许昌', '周口', '新乡', '安阳', '开封', '商丘', '信阳', '驻马店', '平顶山', '焦作', '濮阳', '三门峡', '漯河', '鹤壁'],
'河北': ['石家庄', '唐山', '保定', '邯郸', '沧州', '廊坊', '秦皇岛', '张家口', '衡水', '邢台', '承德'],
'安徽': ['合肥', '芜湖', '安庆', '阜阳', '滁州', '马鞍山', '蚌埠', '宿州', '六安', '宣城', '铜陵', '池州', '淮南', '淮北', '黄山'],
'江西': ['南昌', '赣州', '九江', '宜春', '吉安', '上饶', '抚州', '萍乡', '景德镇', '新余', '鹰潭'],
'陕西': ['西安', '咸阳', '宝鸡', '渭南', '汉中', '延安', '榆林', '安康', '商洛', '铜川'],
'山西': ['太原', '大同', '长治', '临汾', '晋中', '运城', '晋城', '吕梁', '忻州', '阳泉', '朔州'],
'辽宁': ['沈阳', '大连', '鞍山', '抚顺', '锦州', '营口', '盘锦', '丹东', '本溪', '辽阳', '朝阳', '阜新', '葫芦岛', '铁岭'],
'吉林': ['长春', '吉林', '延边', '四平', '通化', '松原', '白城', '辽源', '白山'],
'黑龙江': ['哈尔滨', '大庆', '齐齐哈尔', '牡丹江', '佳木斯', '绥化', '鸡西', '鹤岗', '双鸭山', '伊春', '七台河', '黑河', '大兴安岭'],
'广西': ['南宁', '桂林', '柳州', '北海', '玉林', '百色', '梧州', '贵港', '钦州', '河池', '防城港', '崇左', '来宾', '贺州'],
'内蒙古': ['呼和浩特', '包头', '赤峰', '鄂尔多斯', '通辽', '呼伦贝尔', '乌兰察布', '巴彦淖尔', '兴安', '锡林郭勒', '阿拉善'],
'西藏': ['拉萨', '日喀则', '昌都', '林芝', '山南', '那曲', '阿里'],
'新疆': ['乌鲁木齐', '克拉玛依', '吐鲁番', '哈密', '昌吉', '伊犁', '喀什', '阿克苏', '和田', '塔城', '阿勒泰', '博尔塔拉', '巴音郭楞', '克孜勒苏'],
'宁夏': ['银川', '石嘴山', '吴忠', '固原', '中卫'],
'青海': ['西宁', '海东', '海西', '海南', '海北', '黄南', '玉树', '果洛'],
'甘肃': ['兰州', '天水', '白银', '金昌', '嘉峪关', '武威', '张掖', '平凉', '酒泉', '庆阳', '定西', '陇南', '临夏', '甘南'],
'贵州': ['贵阳', '遵义', '六盘水', '安顺', '毕节', '铜仁', '黔东南', '黔南', '黔西南'],
'云南': ['昆明', '大理', '丽江', '曲靖', '玉溪', '红河', '楚雄', '普洱', '昭通', '文山', '西双版纳', '保山', '德宏', '临沧', '怒江', '迪庆'],
'海南': ['海口', '三亚', '儋州', '三沙', '琼海', '文昌', '万宁', '陵水', '五指山'],
}
const cities = computed(() => regions[province.value] || [])
// 城市中心坐标(用于选中城市时地图跳转)
const cityCoords = {
'北京': [39.9042, 116.4074], '天津': [39.1252, 117.1908], '上海': [31.2304, 121.4737], '重庆': [29.4316, 106.9123],
'广州': [23.1291, 113.2644], '深圳': [22.5431, 114.0579], '东莞': [23.0208, 113.7518], '佛山': [23.0219, 113.1219],
'珠海': [22.2710, 113.5767], '中山': [22.5150, 113.3926], '惠州': [23.1110, 114.4162], '汕头': [23.3545, 116.6822],
'江门': [22.5786, 113.0945], '湛江': [21.2713, 110.3594], '肇庆': [23.0469, 112.4653], '茂名': [21.6630, 110.9252],
'揭阳': [23.5499, 116.3727], '梅州': [24.2884, 116.1176], '清远': [23.6810, 113.0560], '韶关': [24.8104, 113.5975],
'阳江': [21.8583, 111.9822], '河源': [23.7437, 114.7009], '汕尾': [22.7870, 115.3753], '潮州': [23.6564, 116.6300],
'云浮': [22.9150, 112.0445],
'杭州': [30.2741, 120.1551], '宁波': [29.8683, 121.5440], '温州': [28.0000, 120.6994], '绍兴': [30.0300, 120.5800],
'嘉兴': [30.7711, 120.7551], '金华': [29.0792, 119.6474], '台州': [28.6562, 121.4208], '湖州': [30.8931, 120.0875],
'衢州': [28.9359, 118.8742], '舟山': [29.9855, 122.2076], '丽水': [28.4680, 119.9228],
'南京': [32.0603, 118.7969], '苏州': [31.2990, 120.5853], '无锡': [31.4910, 120.3119], '常州': [31.8107, 119.9737],
'南通': [31.9798, 120.8938], '徐州': [34.2044, 117.2858], '扬州': [32.3937, 119.4126], '盐城': [33.3876, 120.1633],
'淮安': [33.5516, 119.1131], '连云港': [34.5969, 119.2215], '镇江': [32.1898, 119.4251], '泰州': [32.4555, 119.9227],
'宿迁': [33.9630, 118.2755],
'福州': [26.0745, 119.2965], '厦门': [24.4798, 118.0895], '泉州': [24.8741, 118.6756], '漳州': [24.5134, 117.6470],
'莆田': [25.4540, 119.0077], '宁德': [26.6657, 119.5479], '龙岩': [25.0751, 117.0170], '三明': [26.2634, 117.6392],
'南平': [26.6417, 118.1779],
'济南': [36.6512, 116.9972], '青岛': [36.0671, 120.3826], '烟台': [37.4645, 121.4479], '潍坊': [36.7068, 119.1618],
'临沂': [35.1046, 118.3564], '淄博': [36.8135, 118.0550], '济宁': [35.4148, 116.5872], '威海': [37.5133, 122.1210],
'泰安': [36.1994, 117.0875], '德州': [37.4363, 116.3594], '聊城': [36.4568, 115.9854], '东营': [37.4341, 118.6746],
'枣庄': [34.8105, 117.3211], '日照': [35.4204, 119.5272], '滨州': [37.3819, 117.9724], '菏泽': [35.2337, 115.4812],
'成都': [30.5728, 104.0668], '绵阳': [31.4675, 104.6791], '德阳': [31.1268, 104.3980], '宜宾': [28.7519, 104.6417],
'南充': [30.7991, 106.0827], '泸州': [28.8717, 105.4430], '自贡': [29.3390, 104.7784], '乐山': [29.5527, 103.7654],
'眉山': [30.0595, 103.8265], '达州': [31.2091, 107.4677], '内江': [29.5802, 105.0584], '遂宁': [30.5329, 105.5929],
'广安': [30.4560, 106.6332], '攀枝花': [26.5823, 101.7186], '资阳': [30.1286, 104.6276], '广元': [32.4355, 105.8450],
'雅安': [29.9765, 103.0094], '巴中': [31.8674, 106.7477],
'武汉': [30.5928, 114.3055], '襄阳': [32.0090, 112.1226], '宜昌': [30.6920, 111.2864], '荆州': [30.3345, 112.2407],
'黄冈': [30.4544, 114.8724], '十堰': [32.6319, 110.7939], '孝感': [30.9178, 113.9168], '荆门': [31.0354, 112.1992],
'鄂州': [30.3908, 114.8950], '黄石': [30.2010, 115.0769], '咸宁': [29.8327, 114.3222], '随州': [31.6902, 113.3826],
'长沙': [28.2282, 112.9388], '株洲': [27.8278, 113.1339], '湘潭': [27.8298, 112.9442], '衡阳': [26.8932, 112.5720],
'岳阳': [29.3572, 113.1288], '常德': [29.0316, 111.6985], '益阳': [28.5889, 112.3551], '邵阳': [27.2389, 111.4673],
'郴州': [25.7705, 113.0145], '怀化': [27.5697, 109.9985], '永州': [26.4207, 111.6132], '娄底': [27.7000, 112.0004],
'湘西': [28.3118, 109.7394],
'郑州': [34.7470, 113.6247], '洛阳': [34.6202, 112.4545], '南阳': [32.9908, 112.5286], '许昌': [34.0355, 113.8523],
'周口': [33.6260, 114.6473], '新乡': [35.3036, 113.9269], '安阳': [36.0969, 114.3931], '开封': [34.7973, 114.3073],
'商丘': [34.4140, 115.6554], '信阳': [32.1471, 114.0932], '驻马店': [32.9815, 114.0220], '平顶山': [33.7662, 113.1924],
'焦作': [35.2159, 113.2418], '濮阳': [35.7624, 115.0297], '三门峡': [34.7731, 111.1999], '漯河': [33.5814, 114.0165],
'鹤壁': [35.8995, 114.2975],
'石家庄': [38.0423, 114.5145], '唐山': [39.6303, 118.1824], '保定': [38.8739, 115.4646], '邯郸': [36.6256, 114.5391],
'沧州': [38.3044, 116.8387], '廊坊': [39.5379, 116.6838], '秦皇岛': [39.8891, 119.5997], '张家口': [40.7680, 114.8862],
'衡水': [37.7389, 115.6707], '邢台': [37.0736, 114.5047], '承德': [40.9495, 117.9593],
'合肥': [31.8206, 117.2272], '芜湖': [31.3525, 118.4329], '安庆': [30.5429, 117.0635], '阜阳': [32.8901, 115.8141],
'滁州': [32.3019, 118.3071], '马鞍山': [31.6714, 118.5071], '蚌埠': [32.9188, 117.3894], '宿州': [33.6476, 116.9639],
'六安': [31.7331, 116.5199], '宣城': [30.9404, 118.7587], '铜陵': [30.8789, 117.8107], '池州': [30.6648, 117.4916],
'淮南': [32.6261, 116.9997], '淮北': [33.9653, 116.7985], '黄山': [29.7153, 118.3370],
'南昌': [28.6829, 115.8582], '赣州': [25.8310, 114.9356], '九江': [29.6619, 115.9973], '宜春': [27.8143, 114.4161],
'吉安': [27.1130, 114.9934], '上饶': [28.4546, 117.9435], '抚州': [27.9479, 116.3581], '萍乡': [27.6229, 113.8548],
'景德镇': [29.2686, 117.1784], '新余': [27.8178, 114.9171], '鹰潭': [28.2588, 117.0692],
'西安': [34.3416, 108.9398], '咸阳': [34.3293, 108.7092], '宝鸡': [34.3621, 107.2373], '渭南': [34.5205, 109.5103],
'汉中': [33.0677, 107.0236], '延安': [36.5854, 109.4888], '榆林': [38.2855, 109.7345], '安康': [32.6849, 109.0291],
'商洛': [33.8683, 109.9202], '铜川': [34.8970, 108.9451],
'太原': [37.8706, 112.5499], '大同': [40.0769, 113.3001], '长治': [36.1954, 113.1164], '临汾': [36.0880, 111.5193],
'晋中': [37.6875, 112.7528], '运城': [35.0265, 111.0070], '晋城': [35.4905, 112.8513], '吕梁': [37.5193, 111.1446],
'忻州': [38.4170, 112.7340], '阳泉': [37.8568, 113.5803], '朔州': [39.3315, 112.4327],
'沈阳': [41.8057, 123.4315], '大连': [38.9140, 121.6148], '鞍山': [41.1078, 122.9945], '抚顺': [41.8796, 123.9572],
'锦州': [41.0952, 121.1270], '营口': [40.6241, 122.2351], '盘锦': [41.1198, 122.0708], '丹东': [40.0024, 124.3567],
'本溪': [41.4870, 123.7716], '辽阳': [41.2691, 123.2378], '朝阳': [41.5737, 120.4508], '阜新': [42.0210, 121.6703],
'葫芦岛': [40.7109, 120.8372], '铁岭': [42.2860, 123.8426],
'长春': [43.8960, 125.3265], '吉林': [43.8379, 126.5493], '延边': [42.8907, 129.5094], '四平': [43.1639, 124.3504],
'通化': [41.7278, 125.9395], '松原': [45.1410, 124.8255], '白城': [45.6193, 122.8389], '辽源': [42.8877, 125.1437],
'白山': [41.9406, 126.4244],
'哈尔滨': [45.8038, 126.5350], '大庆': [46.5967, 125.1041], '齐齐哈尔': [47.3543, 123.9180], '牡丹江': [44.5528, 129.6186],
'佳木斯': [46.7998, 130.3194], '绥化': [46.6368, 126.9692], '鸡西': [45.2953, 130.9661], '鹤岗': [47.3499, 130.2978],
'双鸭山': [46.6467, 131.1585], '伊春': [47.7277, 128.8404], '七台河': [45.7708, 131.0153], '黑河': [50.2454, 127.5284],
'大兴安岭': [50.5506, 126.2050],
'南宁': [22.8170, 108.3665], '桂林': [25.2344, 110.1798], '柳州': [24.3249, 109.4280], '北海': [21.4679, 109.1197],
'玉林': [22.6529, 110.1411], '百色': [23.9020, 106.6184], '梧州': [23.4769, 111.2786], '贵港': [23.1131, 109.5980],
'钦州': [21.9799, 108.6541], '河池': [24.6929, 108.0854], '防城港': [21.6869, 108.3783], '崇左': [22.3795, 107.3652],
'来宾': [23.7332, 109.2213], '贺州': [24.4035, 111.5667],
'呼和浩特': [40.8422, 111.7498], '包头': [40.6571, 109.8402], '赤峰': [42.2576, 118.8888], '鄂尔多斯': [39.6083, 109.7809],
'通辽': [43.6530, 122.2445], '呼伦贝尔': [49.2000, 119.7600], '乌兰察布': [40.9935, 113.1310], '巴彦淖尔': [40.7274, 107.3862],
'兴安': [46.0820, 122.0380], '锡林郭勒': [43.9335, 116.0480], '阿拉善': [38.8483, 105.6878],
'拉萨': [29.6500, 91.1000], '日喀则': [29.2670, 88.8810], '昌都': [31.1400, 97.1700], '林芝': [29.6500, 94.3600],
'山南': [29.2300, 91.7700], '那曲': [31.4700, 92.0600], '阿里': [30.4000, 81.1400],
'乌鲁木齐': [43.8256, 87.6168], '克拉玛依': [45.5750, 84.8820], '吐鲁番': [42.9400, 89.1900], '哈密': [42.8200, 93.5100],
'昌吉': [44.0130, 87.3050], '伊犁': [43.9200, 81.3200], '喀什': [39.4700, 75.9900], '阿克苏': [41.1700, 80.2600],
'和田': [37.1100, 79.9200], '塔城': [46.7500, 82.9800], '阿勒泰': [47.8700, 88.1400],
'博尔塔拉': [44.9000, 82.0700], '巴音郭楞': [41.7700, 86.1500], '克孜勒苏': [39.7100, 76.1700],
'银川': [38.4710, 106.2590], '石嘴山': [38.9860, 106.3850], '吴忠': [37.9990, 106.2010], '固原': [36.0170, 106.2860],
'中卫': [37.5170, 105.1960],
'西宁': [36.6230, 101.7800], '海东': [36.5020, 102.1040], '海西': [37.3730, 97.3710],
'海南': [36.2800, 100.6200], '海北': [36.9600, 100.9000], '黄南': [35.5200, 102.0200], '玉树': [33.0000, 97.0100],
'果洛': [34.4800, 100.2400],
'兰州': [36.0611, 103.8343], '天水': [34.5811, 105.7250], '白银': [36.5447, 104.1382], '金昌': [38.5210, 102.1880],
'嘉峪关': [39.7730, 98.2900], '武威': [37.9277, 102.6379], '张掖': [38.9349, 100.4599], '平凉': [35.5434, 106.6682],
'酒泉': [39.7368, 98.4940], '庆阳': [35.7100, 107.6440], '定西': [35.5800, 104.6250], '陇南': [33.3700, 104.9200],
'临夏': [35.6000, 103.2100], '甘南': [34.9900, 102.9100],
'贵阳': [26.6470, 106.6302], '遵义': [27.7254, 106.9270], '六盘水': [26.5927, 104.8300], '安顺': [26.2537, 105.9462],
'毕节': [27.3020, 105.2900], '铜仁': [27.7300, 109.1800],
'黔东南': [26.5800, 107.9700], '黔南': [26.2600, 107.5200], '黔西南': [25.0900, 104.9000],
'昆明': [25.0389, 102.7183], '大理': [25.6065, 100.2280], '丽江': [26.8720, 100.2300], '曲靖': [25.4900, 103.8000],
'玉溪': [24.3520, 102.5470], '红河': [23.3700, 103.3800], '楚雄': [25.0400, 101.5400], '普洱': [22.8300, 100.9700],
'昭通': [27.3400, 103.7200], '文山': [23.3800, 104.2400], '西双版纳': [22.0100, 100.8000],
'保山': [25.1200, 99.1700], '德宏': [24.4400, 98.5900], '临沧': [23.8900, 100.0900], '怒江': [25.8500, 98.8600],
'迪庆': [27.8200, 99.7100],
'海口': [20.0440, 110.3500], '三亚': [18.2540, 109.5020], '儋州': [19.5210, 109.5810], '三沙': [16.8300, 112.3400],
'琼海': [19.2590, 110.4660], '文昌': [19.5440, 110.7980], '万宁': [18.7970, 110.3890], '陵水': [18.5060, 110.0370],
'五指山': [18.7750, 109.5170],
}
const radiusOptions = [
{ value: 1000, label: '1km' },
{ value: 3000, label: '3km' },
{ value: 5000, label: '5km' },
{ value: 10000, label: '10km' },
{ value: 20000, label: '20km' },
{ value: 50000, label: '50km' },
]
// 选中城市时地图跳转
watch(city, (val, oldVal) => {
if (!val || val === oldVal || !mapRef.value) return
const coord = cityCoords[val]
if (!coord) return
centerLat.value = 0
centerLng.value = 0
mapRef.value.clearRadiusCircle()
mapRef.value.flyTo(coord[0], coord[1], 12)
})
async function doSearch() {
if (!keywords.value.trim()) {
error.value = '请输入搜索关键词'
error.value = ''
// 关键词和坐标必须至少有一个
const kw = keywords.value.trim()
const hasCoord = centerLng.value && centerLat.value
if (!kw && !hasCoord) {
error.value = '请输入关键词或点击地图选择搜索范围'
searched.value = true
return
}
error.value = ''
loading.value = true
searched.value = true
activePoi.value = null
try {
const data = await searchPoi(keywords.value.trim(), city.value.trim(), page.value, offset.value)
const data = await searchPoi(
kw,
city.value,
page.value,
offset.value,
centerLng.value,
centerLat.value,
radius.value,
)
results.value = data.list || []
total.value = data.total || 0
maxPage.value = data.maxPage || 1
page.value = data.page || 1
offset.value = data.offset || 20
// 在地图上标注
if (mapRef.value && results.value.length > 0) {
mapRef.value.addMarkers(results.value)
}
} catch (e) {
error.value = e.response?.data?.message || e.message || '请求失败'
results.value = []
@@ -110,63 +317,92 @@ async function doSearch() {
loading.value = false
}
}
function focusItem(item) {
activePoi.value = item
if (mapRef.value) {
mapRef.value.focusMarker(item)
}
}
function onMarkerClick(poi) {
activePoi.value = poi
}
function onMapClick({ lat, lng }) {
centerLat.value = lat
centerLng.value = lng
// 切换为周边搜索时清除城市条件
province.value = ''
city.value = ''
// 地图上画圆圈
if (mapRef.value) {
mapRef.value.drawRadiusCircle(lat, lng, radius.value)
}
}
function clearCenter() {
centerLat.value = 0
centerLng.value = 0
if (mapRef.value) {
mapRef.value.clearRadiusCircle()
}
}
function provinceChanged() {
city.value = ''
}
</script>
<style>
/* === Global reset === */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.6;
}
/* === App layout === */
.app {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px;
width: 100%;
height: 100%;
position: relative;
}
.header {
text-align: center;
margin-bottom: 32px;
/* === Top search bar === */
.top-bar {
position: absolute;
top: 16px;
left: 16px;
right: 380px;
z-index: 1000;
pointer-events: none;
}
.header h1 {
font-size: 28px;
color: #1a73e8;
}
.subtitle {
font-size: 14px;
color: #666;
margin-top: 4px;
}
.search-box {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
}
.form-row {
.search-form {
display: flex;
gap: 12px;
gap: 10px;
background: #fff;
padding: 12px 16px;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
pointer-events: auto;
max-width: 700px;
}
.input {
flex: 1;
padding: 10px 16px;
padding: 9px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 15px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
@@ -175,17 +411,40 @@ body {
border-color: #1a73e8;
}
.city-input {
max-width: 140px;
.select {
padding: 9px 10px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
background: #fff;
cursor: pointer;
transition: border-color 0.2s;
}
.select:focus {
border-color: #1a73e8;
}
.province-select {
max-width: 110px;
}
.city-select {
max-width: 110px;
}
.radius-select {
max-width: 90px;
}
.btn {
padding: 10px 28px;
padding: 9px 24px;
background: #1a73e8;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
@@ -200,157 +459,187 @@ body {
cursor: not-allowed;
}
.error {
background: #fef2f2;
.center-badge {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.95);
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
color: #1a73e8;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
pointer-events: auto;
}
.clear-center {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: #999;
line-height: 1;
}
.clear-center:hover {
color: #dc2626;
padding: 12px 16px;
}
/* === Side panel === */
.side-panel {
position: absolute;
top: 80px;
right: 16px;
width: 350px;
max-height: calc(100% - 100px);
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 10px;
border-bottom: 1px solid #f0f0f0;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.panel-page {
font-size: 12px;
color: #999;
}
.panel-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.result-card {
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 4px;
}
.result-card:hover {
background: #f5f7fa;
}
.result-card.active {
background: #e8f0fe;
border-left: 3px solid #1a73e8;
}
.card-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 3px;
}
.card-phone {
font-size: 15px;
color: #1a73e8;
font-weight: 600;
margin-bottom: 2px;
}
.card-addr {
font-size: 12px;
color: #888;
}
.panel-footer {
display: flex;
gap: 8px;
justify-content: center;
padding: 10px 16px 14px;
border-top: 1px solid #f0f0f0;
}
.page-btn {
flex: 1;
padding: 7px 0;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover:not(:disabled) {
border-color: #1a73e8;
color: #1a73e8;
}
.page-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.panel-loading,
.panel-error,
.panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
text-align: center;
color: #888;
font-size: 14px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 0;
color: #666;
.panel-error {
color: #dc2626;
}
.panel-empty small {
font-size: 12px;
margin-top: 4px;
}
.spinner {
width: 24px;
height: 24px;
width: 22px;
height: 22px;
border: 3px solid #e5e7eb;
border-top-color: #1a73e8;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty {
text-align: center;
padding: 48px 0;
color: #999;
font-size: 15px;
}
.results {
margin-top: 8px;
}
.result-meta {
font-size: 13px;
color: #999;
margin-bottom: 12px;
}
.card {
background: #fff;
border-radius: 10px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
margin-bottom: 12px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.card-header h3 {
font-size: 16px;
color: #1a1a1a;
}
.tag {
/* === Hint bar === */
.hint-bar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(0, 0, 0, 0.55);
color: #fff;
padding: 6px 18px;
border-radius: 20px;
font-size: 12px;
background: #e8f0fe;
color: #1a73e8;
padding: 2px 10px;
border-radius: 12px;
pointer-events: none;
white-space: nowrap;
}
.card-body {
font-size: 14px;
color: #555;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.info-row:last-child {
margin-bottom: 0;
}
.label {
min-width: 40px;
color: #999;
font-size: 13px;
}
.phone {
color: #1a73e8;
font-weight: 600;
font-size: 16px;
text-decoration: none;
}
.phone:hover {
text-decoration: underline;
}
.na {
color: #ccc;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 24px;
padding-bottom: 32px;
}
.btn-outline {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
color: #333;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-outline:hover:not(:disabled) {
border-color: #1a73e8;
color: #1a73e8;
}
.btn-outline:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-info {
font-size: 14px;
color: #666;
}
</style>
+5 -4
View File
@@ -5,11 +5,12 @@ const api = axios.create({
timeout: 30000,
})
export async function searchPoi(keywords, city = '', page = 1, offset = 20) {
const params = { keywords }
export async function searchPoi(keywords, city = '', page = 1, offset = 20, longitude = 0, latitude = 0, radius = 3000) {
const params = { keywords, page, offset }
if (city) params.city = city
params.page = page
params.offset = offset
if (longitude) params.longitude = longitude
if (latitude) params.latitude = latitude
if (radius) params.radius = radius
const res = await api.get('/poi/search', { params })
return res.data.data
+185
View File
@@ -0,0 +1,185 @@
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mapContainer = ref(null)
let map = null
let markerLayer = null
let currentMarkers = []
let radiusCircle = null
const emit = defineEmits(['marker-click', 'map-click'])
// 高德地图瓦片
const amapTileUrl = 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
const amapSubdomains = ['1', '2', '3', '4']
onMounted(() => {
map = L.map(mapContainer.value, {
center: [39.9042, 116.4074], // 北京
zoom: 12,
zoomControl: true,
})
// 高德地图瓦片层
L.tileLayer(amapTileUrl, {
subdomains: amapSubdomains,
attribution: '&copy; 高德地图',
maxZoom: 18,
}).addTo(map)
markerLayer = L.layerGroup().addTo(map)
// 地图点击 → 设置中心点
map.on('click', (e) => {
emit('map-click', { lat: e.latlng.lat, lng: e.latlng.lng })
})
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
function clearMarkers() {
markerLayer.clearLayers()
currentMarkers = []
}
function addMarkers(pois) {
clearMarkers()
if (!map || !pois.length) return
const bounds = L.latLngBounds()
const pinIcon = L.divIcon({
className: '',
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
<path d="M16 2C8.268 2 2 8.268 2 16c0 9.678 14 24 14 24s14-14.322 14-24C30 8.268 23.732 2 16 2z" fill="#1a73e8" stroke="#fff" stroke-width="2"/>
<circle cx="16" cy="15" r="6" fill="#fff"/>
</svg>`,
iconSize: [32, 42],
iconAnchor: [16, 42],
popupAnchor: [0, -42],
})
pois.forEach((poi) => {
const lat = parseFloat(poi.latitude)
const lng = parseFloat(poi.longitude)
if (isNaN(lat) || isNaN(lng)) return
const marker = L.marker([lat, lng], { icon: pinIcon }).addTo(markerLayer)
marker.bindPopup(`
<div class="map-popup">
<h4>${poi.name}</h4>
<p class="popup-phone">${poi.phone ? `<a href="tel:${poi.phone}">${poi.phone}</a>` : '暂无电话'}</p>
<p class="popup-addr">${poi.address || ''}</p>
${poi.businessArea ? `<p class="popup-area">商圈:${poi.businessArea}</p>` : ''}
</div>
`)
marker.on('click', () => {
emit('marker-click', poi)
})
currentMarkers.push(marker)
bounds.extend([lat, lng])
})
// 自动调整视野
if (pois.length === 1) {
map.setView(bounds.getCenter(), 15)
} else {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 })
}
}
function focusMarker(poi) {
const lat = parseFloat(poi.latitude)
const lng = parseFloat(poi.longitude)
if (isNaN(lat) || isNaN(lng)) return
map.setView([lat, lng], 16)
for (const marker of currentMarkers) {
const pos = marker.getLatLng()
if (Math.abs(pos.lat - lat) < 0.001 && Math.abs(pos.lng - lng) < 0.001) {
marker.openPopup()
break
}
}
}
// 地图跳转到指定坐标
function flyTo(lat, lng, zoom) {
if (!map) return
map.setView([lat, lng], zoom || 12)
}
// 绘制半径圆圈
function drawRadiusCircle(lat, lng, radiusMeters) {
clearRadiusCircle()
if (!map) return
radiusCircle = L.circle([lat, lng], {
radius: radiusMeters,
color: '#1a73e8',
fillColor: '#1a73e8',
fillOpacity: 0.08,
weight: 2,
dashArray: '6, 6',
}).addTo(map)
map.fitBounds(radiusCircle.getBounds(), { padding: [40, 40] })
}
function clearRadiusCircle() {
if (radiusCircle) {
map.removeLayer(radiusCircle)
radiusCircle = null
}
}
defineExpose({ addMarkers, clearMarkers, focusMarker, drawRadiusCircle, clearRadiusCircle, flyTo })
</script>
<style scoped>
.map-container {
width: 100%;
height: 100%;
min-height: 400px;
}
:deep(.map-popup h4) {
margin: 0 0 4px;
font-size: 14px;
color: #333;
}
:deep(.popup-phone) {
margin: 2px 0;
font-size: 15px;
font-weight: 600;
color: #1a73e8;
}
:deep(.popup-phone a) {
color: #1a73e8;
text-decoration: none;
}
:deep(.popup-addr) {
margin: 2px 0;
font-size: 12px;
color: #888;
}
:deep(.popup-area) {
margin: 2px 0;
font-size: 12px;
color: #999;
}
</style>