You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

339 lines
15 KiB

3 days ago
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>