|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8" />
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
|
|
|
|
|
<title>高校雷达网</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #f5f6f8; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
|
|
|
|
|
#map { width: 100%; height: 100%; touch-action: none; }
|
|
|
|
|
|
.status-bar {
|
|
|
|
|
|
position: fixed; top: 12px; left: 50%; z-index: 1000; transform: translateX(-50%);
|
|
|
|
|
|
max-width: 88%; padding: 8px 14px; border-radius: 8px; background: rgba(255,255,255,.94);
|
|
|
|
|
|
color: #374151; font-size: 13px; box-shadow: 0 4px 16px rgba(15,23,42,.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
.status-bar.error { color: #b45309; }
|
|
|
|
|
|
.panel-mask {
|
|
|
|
|
|
position: fixed; inset: 0; z-index: 1100; background: rgba(15,23,42,.28);
|
|
|
|
|
|
opacity: 0; pointer-events: none; transition: opacity .2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.panel-mask.visible { opacity: 1; pointer-events: auto; }
|
|
|
|
|
|
.teacher-panel {
|
|
|
|
|
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 1200;
|
|
|
|
|
|
max-height: 68vh; padding: 18px 18px calc(18px + env(safe-area-inset-bottom));
|
|
|
|
|
|
border-radius: 16px 16px 0 0; background: #fff;
|
|
|
|
|
|
transform: translateY(100%); transition: transform .24s ease;
|
|
|
|
|
|
box-shadow: 0 -8px 28px rgba(15,23,42,.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
.teacher-panel.visible { transform: translateY(0); }
|
|
|
|
|
|
.panel-title { color: #111827; font-size: 18px; font-weight: 600; }
|
|
|
|
|
|
.panel-sub { margin-top: 4px; color: #6b7280; font-size: 13px; }
|
|
|
|
|
|
.panel-scroll { max-height: 52vh; margin-top: 12px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
|
|
|
|
.teacher-row {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
|
|
|
|
padding: 12px 0; border-bottom: 1px solid #eef2f6; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.teacher-name { display: block; color: #111827; font-size: 15px; }
|
|
|
|
|
|
.teacher-dir { display: block; margin-top: 4px; color: #6b7280; font-size: 12px; }
|
|
|
|
|
|
.teacher-arrow { color: #9ca3af; font-size: 22px; }
|
|
|
|
|
|
.panel-empty { padding: 24px; color: #9ca3af; font-size: 14px; text-align: center; }
|
|
|
|
|
|
.slake-map-school-marker {
|
|
|
|
|
|
position: absolute; z-index: 400; pointer-events: auto;
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
|
|
|
|
transform: translate(-6px, -50%); cursor: pointer; border: 0; background: transparent; padding: 0; margin: 0; line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.slake-map-school-marker.is-active { transform: translate(-7px, -50%); }
|
|
|
|
|
|
.slake-map-school-dot {
|
|
|
|
|
|
flex-shrink: 0; width: 12px; height: 12px; box-sizing: border-box;
|
|
|
|
|
|
border: 2px solid #fff; border-radius: 50%; background: #244e98;
|
|
|
|
|
|
box-shadow: 0 0 0 4px rgba(36,78,152,.16), 0 6px 14px rgba(15,23,42,.14);
|
|
|
|
|
|
}
|
|
|
|
|
|
.slake-map-school-marker.is-active .slake-map-school-dot {
|
|
|
|
|
|
width: 13px; height: 13px; background: #1a3d7a;
|
|
|
|
|
|
box-shadow: 0 0 0 6px rgba(36,78,152,.18), 0 8px 18px rgba(15,23,42,.16);
|
|
|
|
|
|
}
|
|
|
|
|
|
.slake-map-school-label {
|
|
|
|
|
|
display: inline-block; border: 1px solid rgba(226,232,240,.92); border-radius: 999px;
|
|
|
|
|
|
background: rgba(255,255,255,.94); color: #475569; font-size: 10.5px; font-weight: 500;
|
|
|
|
|
|
line-height: 1.15; padding: 2px 6px; white-space: nowrap;
|
|
|
|
|
|
box-shadow: 0 5px 12px rgba(15,23,42,.07);
|
|
|
|
|
|
}
|
|
|
|
|
|
.slake-map-school-marker.is-active .slake-map-school-label {
|
|
|
|
|
|
border-color: rgba(36,78,152,.55); color: #1a3d7a; font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div id="map"></div>
|
|
|
|
|
|
<div id="status" class="status-bar" style="display:none"></div>
|
|
|
|
|
|
<div id="panelMask" class="panel-mask"></div>
|
|
|
|
|
|
<div id="teacherPanel" class="teacher-panel">
|
|
|
|
|
|
<div class="panel-title" id="panelTitle">高校老师</div>
|
|
|
|
|
|
<div class="panel-sub" id="panelSub"></div>
|
|
|
|
|
|
<div class="panel-scroll" id="panelScroll"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const TIANDITU_TK = @json($tiandituTk);
|
|
|
|
|
|
const API_BASE = @json($apiBase);
|
|
|
|
|
|
const SUZHOU_CENTER = { lng: 120.585316, lat: 31.298886 };
|
|
|
|
|
|
const SUZHOU_ZOOM = 11;
|
|
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
const token = params.get('token') || '';
|
|
|
|
|
|
|
|
|
|
|
|
let mapInstance = null;
|
|
|
|
|
|
let schoolOverlays = [];
|
|
|
|
|
|
let schools = [];
|
|
|
|
|
|
let activeSchool = null;
|
|
|
|
|
|
let unbindDragPan = null;
|
|
|
|
|
|
let schoolOverlayClass = null;
|
|
|
|
|
|
|
|
|
|
|
|
function setStatus(text, isError) {
|
|
|
|
|
|
const el = document.getElementById('status');
|
|
|
|
|
|
if (!text) { el.style.display = 'none'; return; }
|
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
|
el.className = 'status-bar' + (isError ? ' error' : '');
|
|
|
|
|
|
el.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
|
|
return String(text || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadTianditu() {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
if (window.T) return resolve(window.T);
|
|
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
|
script.src = 'https://api.tianditu.gov.cn/api?v=4.0&tk=' + encodeURIComponent(TIANDITU_TK);
|
|
|
|
|
|
script.async = true;
|
|
|
|
|
|
script.onload = () => window.T ? resolve(window.T) : reject(new Error('天地图 SDK 加载失败'));
|
|
|
|
|
|
script.onerror = () => reject(new Error('天地图脚本加载失败'));
|
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getSchoolOverlayClass(T) {
|
|
|
|
|
|
if (schoolOverlayClass) return schoolOverlayClass;
|
|
|
|
|
|
const proto = {
|
|
|
|
|
|
lnglat: null, options: {}, map: null, _div: null, _onMapChange: null, _listeners: [],
|
|
|
|
|
|
initialize(lnglat, options) {
|
|
|
|
|
|
this.lnglat = lnglat;
|
|
|
|
|
|
this.options = options || {};
|
|
|
|
|
|
this._listeners = [];
|
|
|
|
|
|
},
|
|
|
|
|
|
onAdd(map) {
|
|
|
|
|
|
this.map = map;
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'slake-map-school-marker' + (this.options.active ? ' is-active' : '');
|
|
|
|
|
|
div.setAttribute('role', 'button');
|
|
|
|
|
|
div.style.pointerEvents = 'auto';
|
|
|
|
|
|
div.innerHTML = '<span class="slake-map-school-dot"></span><span class="slake-map-school-label">' + escapeHtml(this.options.name) + '</span>';
|
|
|
|
|
|
this._div = div;
|
|
|
|
|
|
const panes = map.getPanes && map.getPanes();
|
|
|
|
|
|
const overlayPane = panes && (panes.overlayPane || panes.markerPane);
|
|
|
|
|
|
if (overlayPane) overlayPane.appendChild(div);
|
|
|
|
|
|
let moveRaf = 0;
|
|
|
|
|
|
this._onMapChange = () => {
|
|
|
|
|
|
if (moveRaf) return;
|
|
|
|
|
|
moveRaf = requestAnimationFrame(() => { moveRaf = 0; this.update(); });
|
|
|
|
|
|
};
|
|
|
|
|
|
map.addEventListener && map.addEventListener('move', this._onMapChange);
|
|
|
|
|
|
map.addEventListener && map.addEventListener('zoomend', this._onMapChange);
|
|
|
|
|
|
this.update();
|
|
|
|
|
|
this._listeners.forEach(({ type, handler }) => div.addEventListener(type, handler));
|
|
|
|
|
|
},
|
|
|
|
|
|
onRemove() {
|
|
|
|
|
|
if (this.map && this._onMapChange) {
|
|
|
|
|
|
this.map.removeEventListener && this.map.removeEventListener('move', this._onMapChange);
|
|
|
|
|
|
this.map.removeEventListener && this.map.removeEventListener('zoomend', this._onMapChange);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this._div && this._div.parentNode) this._div.parentNode.removeChild(this._div);
|
|
|
|
|
|
this.map = null; this._div = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
update() {
|
|
|
|
|
|
if (!this.map || !this.map.lngLatToLayerPoint || !this._div) return;
|
|
|
|
|
|
const pos = this.map.lngLatToLayerPoint(this.lnglat);
|
|
|
|
|
|
this._div.style.left = pos.x + 'px';
|
|
|
|
|
|
this._div.style.top = pos.y + 'px';
|
|
|
|
|
|
},
|
|
|
|
|
|
setActive(active) {
|
|
|
|
|
|
this.options.active = active;
|
|
|
|
|
|
if (this._div) this._div.classList.toggle('is-active', active);
|
|
|
|
|
|
},
|
|
|
|
|
|
addEventListener(type, handler) {
|
|
|
|
|
|
if (this._div) { this._div.addEventListener(type, handler); return; }
|
|
|
|
|
|
this._listeners.push({ type, handler });
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
schoolOverlayClass = T.Overlay.extend(proto);
|
|
|
|
|
|
return schoolOverlayClass;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createSchoolOverlay(T, school, active) {
|
|
|
|
|
|
const OverlayClass = getSchoolOverlayClass(T);
|
|
|
|
|
|
return new OverlayClass(new T.LngLat(school.longitude, school.latitude), { name: school.name, active });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindMapDragPan(map, T) {
|
|
|
|
|
|
const container = map.getContainer && map.getContainer();
|
|
|
|
|
|
if (!container || !map.panBy) return () => {};
|
|
|
|
|
|
map.disableDrag && map.disableDrag();
|
|
|
|
|
|
const root = container;
|
|
|
|
|
|
const ignoreSelector = '.slake-map-school-marker, .slake-map-school-marker *';
|
|
|
|
|
|
let panning = false, lastX = 0, lastY = 0, pendingDx = 0, pendingDy = 0, panRaf = 0;
|
|
|
|
|
|
function shouldIgnore(target) {
|
|
|
|
|
|
return target instanceof Element && target.closest(ignoreSelector);
|
|
|
|
|
|
}
|
|
|
|
|
|
function startPan(x, y) { panning = true; lastX = x; lastY = y; root.style.cursor = 'grabbing'; }
|
|
|
|
|
|
function movePan(x, y) {
|
|
|
|
|
|
if (!panning) return;
|
|
|
|
|
|
const dx = x - lastX, dy = y - lastY;
|
|
|
|
|
|
if (!dx && !dy) return;
|
|
|
|
|
|
lastX = x; lastY = y; pendingDx += dx; pendingDy += dy;
|
|
|
|
|
|
if (panRaf) return;
|
|
|
|
|
|
panRaf = requestAnimationFrame(() => {
|
|
|
|
|
|
panRaf = 0;
|
|
|
|
|
|
if (!pendingDx && !pendingDy) return;
|
|
|
|
|
|
map.panBy(new T.Point(-pendingDx, -pendingDy));
|
|
|
|
|
|
pendingDx = 0; pendingDy = 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
function endPan() { panning = false; root.style.cursor = 'grab'; }
|
|
|
|
|
|
const onTouchStart = (e) => {
|
|
|
|
|
|
if (shouldIgnore(e.target) || e.touches.length !== 1) return;
|
|
|
|
|
|
startPan(e.touches[0].clientX, e.touches[0].clientY);
|
|
|
|
|
|
};
|
|
|
|
|
|
const onTouchMove = (e) => {
|
|
|
|
|
|
if (!panning || e.touches.length !== 1) return;
|
|
|
|
|
|
movePan(e.touches[0].clientX, e.touches[0].clientY);
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
};
|
|
|
|
|
|
root.style.cursor = 'grab';
|
|
|
|
|
|
root.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
|
|
|
|
root.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
|
|
|
|
root.addEventListener('touchend', endPan);
|
|
|
|
|
|
root.addEventListener('touchcancel', endPan);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
root.removeEventListener('touchstart', onTouchStart);
|
|
|
|
|
|
root.removeEventListener('touchmove', onTouchMove);
|
|
|
|
|
|
root.removeEventListener('touchend', endPan);
|
|
|
|
|
|
root.removeEventListener('touchcancel', endPan);
|
|
|
|
|
|
root.style.cursor = '';
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyOverlayPassthrough(map) {
|
|
|
|
|
|
const panes = map.getPanes && map.getPanes();
|
|
|
|
|
|
if (panes) {
|
|
|
|
|
|
Object.entries(panes).forEach(([key, pane]) => {
|
|
|
|
|
|
if (!pane || ['mapPane','tilePane','floatPane'].includes(key)) return;
|
|
|
|
|
|
pane.style.pointerEvents = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const root = map.getContainer && map.getContainer();
|
|
|
|
|
|
if (root) {
|
|
|
|
|
|
root.querySelectorAll('.slake-map-school-marker').forEach((el) => { el.style.pointerEvents = 'auto'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectSchool(school) {
|
|
|
|
|
|
activeSchool = school;
|
|
|
|
|
|
schoolOverlays.forEach(({ school: s, overlay }) => overlay.setActive(s.id === school.id));
|
|
|
|
|
|
openTeacherPanel(school);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openTeacherPanel(school) {
|
|
|
|
|
|
document.getElementById('panelTitle').textContent = school.name || '高校老师';
|
|
|
|
|
|
document.getElementById('panelSub').textContent = (school.city || '') + ' · ' + (school.teachers_count || 0) + ' 位老师';
|
|
|
|
|
|
const scroll = document.getElementById('panelScroll');
|
|
|
|
|
|
const teachers = school.teachers || [];
|
|
|
|
|
|
if (!teachers.length) {
|
|
|
|
|
|
scroll.innerHTML = '<div class="panel-empty">该高校暂无老师数据</div>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
scroll.innerHTML = teachers.map((t) => (
|
|
|
|
|
|
'<div class="teacher-row" data-id="' + t.id + '">' +
|
|
|
|
|
|
'<div><span class="teacher-name">' + escapeHtml(t.name) + '</span>' +
|
|
|
|
|
|
'<span class="teacher-dir">' + escapeHtml(t.research_direction || '暂无研究方向') + '</span></div>' +
|
|
|
|
|
|
'<span class="teacher-arrow">›</span></div>'
|
|
|
|
|
|
)).join('');
|
|
|
|
|
|
scroll.querySelectorAll('.teacher-row').forEach((row) => {
|
|
|
|
|
|
row.addEventListener('click', () => openTeacher(Number(row.dataset.id)));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('panelMask').classList.add('visible');
|
|
|
|
|
|
document.getElementById('teacherPanel').classList.add('visible');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeTeacherPanel() {
|
|
|
|
|
|
document.getElementById('panelMask').classList.remove('visible');
|
|
|
|
|
|
document.getElementById('teacherPanel').classList.remove('visible');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openTeacher(id) {
|
|
|
|
|
|
const url = '/subpkg/teacher-detail/index?id=' + id;
|
|
|
|
|
|
if (typeof wx !== 'undefined' && wx.miniProgram && wx.miniProgram.navigateTo) {
|
|
|
|
|
|
wx.miniProgram.navigateTo({ url });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.location.href = url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchMapData() {
|
|
|
|
|
|
const res = await fetch(API_BASE + '/radar-map', {
|
|
|
|
|
|
headers: { Accept: 'application/json', Authorization: 'Bearer ' + token },
|
|
|
|
|
|
});
|
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
|
if (!res.ok) throw new Error(body.message || '地图数据加载失败');
|
|
|
|
|
|
return body.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function initMap() {
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
setStatus('缺少登录凭证,请从小程序重新进入', true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!TIANDITU_TK) {
|
|
|
|
|
|
setStatus('未配置天地图 Key', true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus('地图加载中...');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchMapData();
|
|
|
|
|
|
schools = data.schools || [];
|
|
|
|
|
|
if (!schools.length) {
|
|
|
|
|
|
setStatus('暂无已配置坐标的高校', true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const T = await loadTianditu();
|
|
|
|
|
|
const container = document.getElementById('map');
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
mapInstance = new T.Map(container);
|
|
|
|
|
|
mapInstance.enableScrollWheelZoom && mapInstance.enableScrollWheelZoom();
|
|
|
|
|
|
mapInstance.enableDrag && mapInstance.enableDrag();
|
|
|
|
|
|
mapInstance.enableAutoResize && mapInstance.enableAutoResize();
|
|
|
|
|
|
mapInstance.centerAndZoom(new T.LngLat(SUZHOU_CENTER.lng, SUZHOU_CENTER.lat), SUZHOU_ZOOM);
|
|
|
|
|
|
|
|
|
|
|
|
schoolOverlays = [];
|
|
|
|
|
|
schools.forEach((school) => {
|
|
|
|
|
|
const overlay = createSchoolOverlay(T, school, false);
|
|
|
|
|
|
overlay.addEventListener('click', () => selectSchool(school));
|
|
|
|
|
|
mapInstance.addOverLay(overlay);
|
|
|
|
|
|
schoolOverlays.push({ school, overlay });
|
|
|
|
|
|
});
|
|
|
|
|
|
applyOverlayPassthrough(mapInstance);
|
|
|
|
|
|
unbindDragPan = bindMapDragPan(mapInstance, T);
|
|
|
|
|
|
setStatus('');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setStatus(e.message || '地图初始化失败', true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('panelMask').addEventListener('click', closeTeacherPanel);
|
|
|
|
|
|
initMap();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|