meeting-point/static/js/app.js
2026-01-09 18:52:32 +08:00

693 lines
21 KiB
JavaScript
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.

/**
* 会面点 - Meeting Point
* 前端应用主逻辑
*/
// ========================================
// 全局状态
// ========================================
const state = {
map: null,
amapKey: '', // Web服务API Key
amapJsKey: '', // JS API Key
amapJsSecret: '', // JS API 安全密钥
locations: [], // 参与者位置列表
markers: [], // 位置标记
centerMarker: null, // 中心点标记
poiMarkers: [], // POI标记
center: null, // 计算出的中心点
searchCircle: null, // 搜索范围圆
infoWindow: null, // 信息窗口
currentPOIs: [], // 当前搜索结果
colors: [
'#3b82f6', '#ef4444', '#10b981', '#8b5cf6',
'#f59e0b', '#ec4899', '#06b6d4', '#84cc16'
]
};
// ========================================
// 初始化
// ========================================
document.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
initMap();
bindEvents();
});
async function loadConfig() {
try {
const response = await fetch('/api/config');
const data = await response.json();
state.amapKey = data.amap_key;
state.amapJsKey = data.amap_js_key || data.amap_key;
state.amapJsSecret = data.amap_js_secret;
// 配置安全密钥必须在加载JS API之前设置
if (state.amapJsSecret) {
window._AMapSecurityConfig = {
securityJsCode: state.amapJsSecret
};
}
// 动态加载高德地图JS API
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${state.amapJsKey}&plugin=AMap.PlaceSearch,AMap.Geocoder,AMap.AutoComplete`;
script.onload = resolve;
script.onerror = () => reject(new Error('加载高德地图JS API失败'));
document.head.appendChild(script);
});
} catch (error) {
console.error('加载配置失败:', error);
alert('加载配置失败,请检查后端服务是否启动');
}
}
function initMap() {
// 初始化地图
state.map = new AMap.Map('mapContainer', {
zoom: 12,
center: [116.397428, 39.90923], // 默认北京
mapStyle: 'amap://styles/dark', // 深色主题
viewMode: '2D'
});
// 初始化信息窗口
state.infoWindow = new AMap.InfoWindow({
isCustom: true,
autoMove: true,
offset: new AMap.Pixel(0, -40)
});
// 地图点击事件
state.map.on('click', handleMapClick);
// 尝试定位到用户位置
AMap.plugin('AMap.Geolocation', () => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 10000
});
geolocation.getCurrentPosition((status, result) => {
if (status === 'complete') {
state.map.setCenter([result.position.lng, result.position.lat]);
}
});
});
}
// ========================================
// 事件绑定
// ========================================
function bindEvents() {
// 搜索输入
const searchInput = document.getElementById('searchInput');
const clearSearch = document.getElementById('clearSearch');
const searchTips = document.getElementById('searchTips');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const value = e.target.value.trim();
clearSearch.style.display = value ? 'block' : 'none';
clearTimeout(searchTimeout);
if (value.length >= 2) {
searchTimeout = setTimeout(() => fetchSearchTips(value), 300);
} else {
searchTips.classList.remove('active');
}
});
clearSearch.addEventListener('click', () => {
searchInput.value = '';
clearSearch.style.display = 'none';
searchTips.classList.remove('active');
});
// 点击外部关闭搜索提示
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-box')) {
searchTips.classList.remove('active');
}
});
// 快捷标签
document.querySelectorAll('.tag').forEach(tag => {
tag.addEventListener('click', () => {
document.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
tag.classList.add('active');
document.getElementById('poiKeywords').value = tag.dataset.keyword;
});
});
// 搜索半径滑块
const radiusSlider = document.getElementById('searchRadius');
const radiusValue = document.getElementById('radiusValue');
radiusSlider.addEventListener('input', () => {
const value = parseInt(radiusSlider.value);
radiusValue.textContent = value >= 1000 ? `${value / 1000} 公里` : `${value}`;
});
// 搜索按钮
document.getElementById('searchBtn').addEventListener('click', handleSearch);
// POI关键词输入
document.getElementById('poiKeywords').addEventListener('input', () => {
document.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
});
}
// ========================================
// 搜索提示
// ========================================
async function fetchSearchTips(keywords) {
const searchTips = document.getElementById('searchTips');
try {
const response = await fetch(`/api/tips?keywords=${encodeURIComponent(keywords)}&city=`);
const data = await response.json();
if (data.tips && data.tips.length > 0) {
renderSearchTips(data.tips);
} else {
searchTips.classList.remove('active');
}
} catch (error) {
console.error('获取搜索提示失败:', error);
}
}
function renderSearchTips(tips) {
const searchTips = document.getElementById('searchTips');
searchTips.innerHTML = tips.map(tip => `
<div class="search-tip-item" data-lng="${tip.location.lng}" data-lat="${tip.location.lat}" data-name="${tip.name}" data-address="${tip.district}${tip.address}">
<div class="name">${tip.name}</div>
<div class="address">${tip.district}${tip.address}</div>
</div>
`).join('');
// 绑定点击事件
searchTips.querySelectorAll('.search-tip-item').forEach(item => {
item.addEventListener('click', () => {
const location = {
name: item.dataset.name,
address: item.dataset.address,
coordinate: {
lng: parseFloat(item.dataset.lng),
lat: parseFloat(item.dataset.lat)
}
};
addLocation(location);
// 清理搜索框
document.getElementById('searchInput').value = '';
document.getElementById('clearSearch').style.display = 'none';
searchTips.classList.remove('active');
});
});
searchTips.classList.add('active');
}
// ========================================
// 地图点击添加位置
// ========================================
function handleMapClick(e) {
const lnglat = e.lnglat;
// 逆地理编码获取地址
AMap.plugin('AMap.Geocoder', () => {
const geocoder = new AMap.Geocoder();
geocoder.getAddress([lnglat.lng, lnglat.lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
const address = result.regeocode.formattedAddress;
const location = {
name: `位置 ${state.locations.length + 1}`,
address: address,
coordinate: {
lng: lnglat.lng,
lat: lnglat.lat
}
};
addLocation(location);
}
});
});
}
// ========================================
// 位置管理
// ========================================
function addLocation(location) {
state.locations.push(location);
// 添加地图标记
const index = state.locations.length - 1;
const color = state.colors[index % state.colors.length];
const marker = new AMap.Marker({
position: [location.coordinate.lng, location.coordinate.lat],
content: `<div class="custom-marker" style="background: ${color}">${index + 1}</div>`,
offset: new AMap.Pixel(-18, -18)
});
marker.on('click', () => {
showInfoWindow(marker, location);
});
state.markers.push(marker);
state.map.add(marker);
// 调整视野
if (state.locations.length > 1) {
state.map.setFitView(state.markers);
} else {
state.map.setCenter([location.coordinate.lng, location.coordinate.lat]);
state.map.setZoom(14);
}
// 更新UI
updateLocationList();
updateSearchButton();
// 清除之前的搜索结果
clearSearchResults();
}
function removeLocation(index) {
// 移除标记
state.map.remove(state.markers[index]);
state.markers.splice(index, 1);
state.locations.splice(index, 1);
// 更新剩余标记的编号和颜色
state.markers.forEach((marker, i) => {
const color = state.colors[i % state.colors.length];
marker.setContent(`<div class="custom-marker" style="background: ${color}">${i + 1}</div>`);
});
// 更新UI
updateLocationList();
updateSearchButton();
// 调整视野
if (state.markers.length > 0) {
state.map.setFitView(state.markers);
}
// 清除之前的搜索结果
clearSearchResults();
}
function updateLocationList() {
const listEl = document.getElementById('locationList');
const countEl = document.getElementById('locationCount');
countEl.textContent = state.locations.length;
if (state.locations.length === 0) {
listEl.innerHTML = `
<li class="empty-state">
<span class="empty-icon">🗺️</span>
<span>还没有添加位置</span>
<span class="empty-hint">搜索地址或点击地图开始</span>
</li>
`;
return;
}
listEl.innerHTML = state.locations.map((loc, index) => {
const color = state.colors[index % state.colors.length];
return `
<li class="location-item">
<div class="location-marker" style="background: ${color}">${index + 1}</div>
<div class="location-info">
<div class="location-name">${loc.name}</div>
<div class="location-address">${loc.address || '暂无地址'}</div>
</div>
<button class="location-remove" onclick="removeLocation(${index})">×</button>
</li>
`;
}).join('');
}
function updateSearchButton() {
const btn = document.getElementById('searchBtn');
btn.disabled = state.locations.length < 2;
}
// ========================================
// 搜索功能
// ========================================
async function handleSearch() {
const keywords = document.getElementById('poiKeywords').value.trim();
const radius = parseInt(document.getElementById('searchRadius').value);
if (!keywords) {
alert('请输入搜索关键词');
return;
}
if (state.locations.length < 2) {
alert('请至少添加2个位置');
return;
}
showLoading(true);
try {
// 1. 计算中心点
const centerResponse = await fetch('/api/center', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: state.locations })
});
const centerData = await centerResponse.json();
state.center = centerData.center;
// 显示中心点标记
showCenterMarker();
// 2. 搜索周边POI
const searchResponse = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
center: state.center,
keywords: keywords,
radius: radius
})
});
const searchData = await searchResponse.json();
// 显示搜索结果
showSearchResults(searchData.pois, radius);
} catch (error) {
console.error('搜索失败:', error);
alert('搜索失败,请稍后重试');
} finally {
showLoading(false);
}
}
function showCenterMarker() {
// 移除旧的中心点标记
if (state.centerMarker) {
state.map.remove(state.centerMarker);
}
// 创建新的中心点标记
state.centerMarker = new AMap.Marker({
position: [state.center.lng, state.center.lat],
content: '<div class="center-marker">⭐</div>',
offset: new AMap.Pixel(-24, -24),
zIndex: 200
});
state.map.add(state.centerMarker);
// 显示中心点信息
document.getElementById('centerInfo').style.display = 'block';
}
function showSearchResults(pois, radius) {
// 清除旧的POI标记
clearPOIMarkers();
// 显示搜索范围圆
if (state.searchCircle) {
state.map.remove(state.searchCircle);
}
state.searchCircle = new AMap.Circle({
center: [state.center.lng, state.center.lat],
radius: radius,
fillColor: '#f59e0b',
fillOpacity: 0.1,
strokeColor: '#f59e0b',
strokeWeight: 2,
strokeOpacity: 0.6
});
state.map.add(state.searchCircle);
// 添加POI标记
pois.forEach((poi, index) => {
const marker = new AMap.Marker({
position: [poi.location.lng, poi.location.lat],
content: `<div class="poi-marker">${index + 1}</div>`,
offset: new AMap.Pixel(-16, -16),
zIndex: 100
});
marker.on('click', () => {
showPOIInfoWindow(marker, poi);
highlightResult(index);
});
state.poiMarkers.push(marker);
state.map.add(marker);
});
// 渲染结果列表
renderResultList(pois);
// 调整视野(右侧留出空间给浮动面板)
const allMarkers = [...state.markers, state.centerMarker, ...state.poiMarkers];
state.map.setFitView(allMarkers, false, [80, 420, 80, 80]);
}
function renderResultList(pois) {
const panel = document.getElementById('floatingResults');
const list = document.getElementById('floatingResultList');
const count = document.getElementById('floatingResultCount');
const filterInput = document.getElementById('resultFilter');
// 保存POI数据供筛选使用
state.currentPOIs = pois;
count.textContent = pois.length;
if (pois.length === 0) {
list.innerHTML = `
<li class="floating-result-empty">
<span class="icon">😕</span>
<span>未找到相关地点</span>
<span style="font-size: 0.8rem; opacity: 0.7;">尝试增大搜索半径或更换关键词</span>
</li>
`;
} else {
list.innerHTML = pois.map((poi, index) => {
const dist = parseDistance(poi.distance);
return `
<li class="floating-result-item" data-index="${index}" data-name="${poi.name}">
<div class="rank">${index + 1}</div>
<div class="info">
<div class="name">${poi.name}</div>
<div class="address">${poi.address || poi.type}</div>
${poi.tel ? `<div class="tel">📞 <a href="tel:${poi.tel}" onclick="event.stopPropagation()">${poi.tel}</a></div>` : ''}
</div>
<div class="distance-badge">
<span class="num">${dist.value}</span>
<span class="unit">${dist.unit}</span>
</div>
</li>
`}).join('');
// 绑定点击事件
list.querySelectorAll('.floating-result-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const index = parseInt(item.dataset.index);
focusPOI(index);
});
});
}
panel.style.display = 'flex';
filterInput.value = '';
// 绑定关闭按钮事件
document.getElementById('closeFloatingResults').onclick = () => {
panel.style.display = 'none';
};
// 绑定筛选事件
filterInput.oninput = (e) => {
const keyword = e.target.value.trim().toLowerCase();
filterResults(keyword);
};
}
// 解析距离为数值和单位
function parseDistance(distance) {
const d = parseInt(distance);
if (d >= 1000) {
return { value: (d / 1000).toFixed(1), unit: '公里' };
}
return { value: d, unit: '米' };
}
// 筛选结果
function filterResults(keyword) {
const items = document.querySelectorAll('.floating-result-item');
let visibleCount = 0;
items.forEach(item => {
const name = item.dataset.name?.toLowerCase() || '';
const address = item.querySelector('.address')?.textContent.toLowerCase() || '';
if (!keyword || name.includes(keyword) || address.includes(keyword)) {
item.classList.remove('hidden');
visibleCount++;
} else {
item.classList.add('hidden');
}
});
// 更新显示数量
document.getElementById('floatingResultCount').textContent = visibleCount;
}
function formatDistance(distance) {
const d = parseInt(distance);
if (d >= 1000) {
return (d / 1000).toFixed(1) + ' 公里';
}
return d + ' 米';
}
function focusPOI(index) {
const marker = state.poiMarkers[index];
const poi = state.currentPOIs[index];
if (marker && poi) {
// 先高亮结果
highlightResult(index);
// 关闭当前信息窗口
state.infoWindow.close();
// 使用 panTo 平滑移动到目标位置
const position = marker.getPosition();
state.map.panTo(position);
// 延迟设置缩放和打开信息窗口,确保地图移动完成
setTimeout(() => {
state.map.setZoom(16);
showPOIInfoWindow(marker, poi);
}, 300);
}
}
function highlightResult(index) {
document.querySelectorAll('.floating-result-item').forEach((item, i) => {
item.classList.toggle('active', i === index);
});
// 滚动到对应项
const activeItem = document.querySelector(`.floating-result-item[data-index="${index}"]`);
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ========================================
// 信息窗口
// ========================================
function showInfoWindow(marker, location) {
const content = `
<div class="info-window">
<h3>${location.name}</h3>
<p>${location.address || '暂无地址'}</p>
</div>
`;
state.infoWindow.setContent(content);
state.infoWindow.open(state.map, marker.getPosition());
}
function showPOIInfoWindow(marker, poi) {
const distText = formatDistance(poi.distance);
const navUrl = `https://uri.amap.com/navigation?to=${poi.location.lng},${poi.location.lat},${encodeURIComponent(poi.name)}&mode=car&coordinate=gaode`;
const content = `
<div class="info-window">
<h3>${poi.name}</h3>
<p class="distance">🎯 距中心点: ${distText}</p>
${poi.address ? `<p>📍 ${poi.address}</p>` : ''}
${poi.tel ? `<p class="tel">📞 <a href="tel:${poi.tel}">${poi.tel}</a></p>` : ''}
<a href="${navUrl}" target="_blank" class="nav-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71L12 2z"/>
</svg>
导航前往
</a>
</div>
`;
state.infoWindow.setContent(content);
state.infoWindow.open(state.map, marker.getPosition());
}
// ========================================
// 工具函数
// ========================================
function clearSearchResults() {
// 隐藏结果区域
document.getElementById('floatingResults').style.display = 'none';
document.getElementById('centerInfo').style.display = 'none';
document.getElementById('resultFilter').value = '';
// 清除标记
clearPOIMarkers();
if (state.centerMarker) {
state.map.remove(state.centerMarker);
state.centerMarker = null;
}
if (state.searchCircle) {
state.map.remove(state.searchCircle);
state.searchCircle = null;
}
state.center = null;
state.currentPOIs = [];
}
function clearPOIMarkers() {
state.poiMarkers.forEach(marker => {
state.map.remove(marker);
});
state.poiMarkers = [];
}
function showLoading(show) {
document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none';
}
// ========================================
// 暴露全局函数
// ========================================
window.removeLocation = removeLocation;
window.focusPOI = focusPOI;