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.

391 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!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; }
.college-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 0; border-bottom: 1px solid #eef2f6; cursor: pointer;
}
.college-name { display: block; color: #111827; font-size: 15px; }
.college-count { display: block; margin-top: 4px; color: #6b7280; font-size: 12px; }
.panel-back {
display: inline-flex; align-items: center; gap: 4px; margin-bottom: 8px;
color: #244e98; font-size: 13px; cursor: pointer;
}
.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 = 9;
const params = new URLSearchParams(window.location.search);
const token = params.get('token') || '';
let mapInstance = null;
let schoolOverlays = [];
let schools = [];
let activeSchool = null;
let activeCollege = null;
let panelMode = 'colleges';
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;
activeCollege = null;
panelMode = 'colleges';
schoolOverlays.forEach(({ school: s, overlay }) => overlay.setActive(s.id === school.id));
openCollegePanel(school);
}
function openCollegePanel(school) {
document.getElementById('panelTitle').textContent = school.name || '高校学院';
document.getElementById('panelSub').textContent = (school.city || '') + ' · ' + (school.teachers_count || 0) + ' 位老师';
const scroll = document.getElementById('panelScroll');
const colleges = school.colleges || [];
if (!colleges.length) {
scroll.innerHTML = '<div class="panel-empty">该高校暂无学院数据</div>';
} else {
scroll.innerHTML = colleges.map((c) => (
'<div class="college-row" data-key="' + escapeHtml(c.key) + '">' +
'<div><span class="college-name">' + escapeHtml(c.name) + '</span>' +
'<span class="college-count">' + (c.teachers_count || 0) + ' 位老师</span></div>' +
'<span class="teacher-arrow"></span></div>'
)).join('');
scroll.querySelectorAll('.college-row').forEach((row) => {
row.addEventListener('click', () => {
const key = row.dataset.key;
const college = colleges.find((item) => item.key === key);
if (college) openTeacherPanel(school, college);
});
});
}
document.getElementById('panelMask').classList.add('visible');
document.getElementById('teacherPanel').classList.add('visible');
}
function openTeacherPanel(school, college) {
activeCollege = college;
panelMode = 'teachers';
document.getElementById('panelTitle').textContent = college.name || '学院老师';
document.getElementById('panelSub').textContent = (school.name || '') + ' · ' + (college.teachers_count || 0) + ' 位老师';
const scroll = document.getElementById('panelScroll');
const teachers = college.teachers || [];
const backHtml = '<div class="panel-back" id="panelBack"> 返回学院列表</div>';
if (!teachers.length) {
scroll.innerHTML = backHtml + '<div class="panel-empty">该学院暂无老师数据</div>';
} else {
scroll.innerHTML = backHtml + 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)));
});
}
const backBtn = document.getElementById('panelBack');
if (backBtn) {
backBtn.addEventListener('click', (e) => {
e.stopPropagation();
openCollegePanel(school);
});
}
}
function closeTeacherPanel() {
activeSchool = null;
activeCollege = null;
panelMode = 'colleges';
schoolOverlays.forEach(({ overlay }) => overlay.setActive(false));
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>