|
@@ -0,0 +1,207 @@
|
|
|
|
|
+'use strict';
|
|
|
|
|
+
|
|
|
|
|
+// 校区地图:中国地图 + 学校点位(启用=涟漪点亮,停用/未定位=灰点)+ 右侧列表。
|
|
|
|
|
+// 数据:GET /api/schools(JSON 种子 + 运行时合并);底图:/vendor/china-map.json
|
|
|
|
|
+(function () {
|
|
|
|
|
+ const $ = (id) => document.getElementById(id);
|
|
|
|
|
+ const CENTER = [104.5, 36];
|
|
|
|
|
+ const ZOOM = 1.55;
|
|
|
|
|
+
|
|
|
|
|
+ let chart = null;
|
|
|
|
|
+ let schools = [];
|
|
|
|
|
+ let inited = false;
|
|
|
|
|
+ let loading = false;
|
|
|
|
|
+ let selectedId = null;
|
|
|
|
|
+
|
|
|
|
|
+ function esc(value) {
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.textContent = String(value == null ? '' : value);
|
|
|
|
|
+ return div.innerHTML;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fmtDate(ymd) {
|
|
|
|
|
+ const s = String(ymd || '');
|
|
|
|
|
+ return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}/${Number(s.slice(6, 8))}` : '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function opsText(school) {
|
|
|
|
|
+ if (!school.ops) return '运营数据不可用';
|
|
|
|
|
+ if (!school.ops.shopCount) return '未关联运营数据';
|
|
|
|
|
+ return `${school.ops.shopCount} 店${school.ops.lastDate ? ' · 最近 ' + fmtDate(school.ops.lastDate) : ''}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function toPoint(school) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: school.name,
|
|
|
|
|
+ value: [school.lng, school.lat],
|
|
|
|
|
+ schoolId: school.id,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function buildOption() {
|
|
|
|
|
+ const on = schools.filter((s) => s.enabled && s.lng != null && s.lat != null);
|
|
|
|
|
+ const off = schools.filter((s) => !(s.enabled && s.lng != null && s.lat != null));
|
|
|
|
|
+ return {
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ trigger: 'item',
|
|
|
|
|
+ backgroundColor: 'rgba(255,255,255,.96)',
|
|
|
|
|
+ borderColor: '#e8e2db',
|
|
|
|
|
+ borderWidth: 1,
|
|
|
|
|
+ padding: [8, 10],
|
|
|
|
|
+ textStyle: { color: '#3f3a35', fontSize: 12 },
|
|
|
|
|
+ formatter(params) {
|
|
|
|
|
+ const school = schools.find((s) => s.id === params.data.schoolId);
|
|
|
|
|
+ if (!school) return params.name;
|
|
|
|
|
+ const status = school.enabled ? '已启用' : '已停用';
|
|
|
|
|
+ const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
|
|
|
|
|
+ return `<b>${esc(school.name)}</b><br>${esc(school.province || '—')}${school.city ? ' · ' + esc(school.city) : ''}<br>${status} · ${opsText(school)}<br><span style="color:#9a938c">${point}</span>`;
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ geo: {
|
|
|
|
|
+ map: 'china',
|
|
|
|
|
+ roam: true,
|
|
|
|
|
+ center: CENTER,
|
|
|
|
|
+ zoom: ZOOM,
|
|
|
|
|
+ scaleLimit: { min: 1, max: 12 },
|
|
|
|
|
+ itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
|
|
|
|
|
+ emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: false } },
|
|
|
|
|
+ select: { disabled: true },
|
|
|
|
|
+ },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'on',
|
|
|
|
|
+ type: 'effectScatter',
|
|
|
|
|
+ coordinateSystem: 'geo',
|
|
|
|
|
+ data: on.map(toPoint),
|
|
|
|
|
+ symbolSize: 11,
|
|
|
|
|
+ showEffectOn: 'render',
|
|
|
|
|
+ rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
|
|
|
|
|
+ itemStyle: { color: '#ff6b35', shadowBlur: 8, shadowColor: 'rgba(255,107,53,.45)' },
|
|
|
|
|
+ label: {
|
|
|
|
|
+ show: true,
|
|
|
|
|
+ position: 'right',
|
|
|
|
|
+ distance: 6,
|
|
|
|
|
+ formatter: (p) => p.name,
|
|
|
|
|
+ fontSize: 11,
|
|
|
|
|
+ color: '#5b534c',
|
|
|
|
|
+ },
|
|
|
|
|
+ emphasis: { scale: 1.4 },
|
|
|
|
|
+ zlevel: 2,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'off',
|
|
|
|
|
+ type: 'scatter',
|
|
|
|
|
+ coordinateSystem: 'geo',
|
|
|
|
|
+ data: off.map(toPoint),
|
|
|
|
|
+ symbolSize: 6,
|
|
|
|
|
+ itemStyle: { color: '#b8b2ad' },
|
|
|
|
|
+ label: { show: false },
|
|
|
|
|
+ zlevel: 1,
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderMap(force) {
|
|
|
|
|
+ const box = $('schoolsMap');
|
|
|
|
|
+ if (!box) return;
|
|
|
|
|
+ if (!chart) chart = echarts.init(box);
|
|
|
|
|
+ chart.setOption(buildOption(), !!force);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderList(data) {
|
|
|
|
|
+ const list = $('schoolsList');
|
|
|
|
|
+ const count = $('schoolsCount');
|
|
|
|
|
+ if (!list) return;
|
|
|
|
|
+ const enabled = data.enabledCount || 0;
|
|
|
|
|
+ if (count) count.textContent = `${enabled} / ${schools.length} 已启用`;
|
|
|
|
|
+
|
|
|
|
|
+ if (!schools.length) {
|
|
|
|
|
+ list.innerHTML = '<div class="empty-hint ops-empty" style="min-height:220px"><p>还没有学校</p><small>内置学校缺失,请检查 server/schools.seed.json</small></div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const groups = new Map();
|
|
|
|
|
+ for (const school of schools) {
|
|
|
|
|
+ const key = school.province || '未标注省份';
|
|
|
|
|
+ if (!groups.has(key)) groups.set(key, []);
|
|
|
|
|
+ groups.get(key).push(school);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ list.innerHTML = [...groups.entries()].map(([province, items]) => {
|
|
|
|
|
+ const onCount = items.filter((s) => s.enabled && s.lng != null).length;
|
|
|
|
|
+ const rows = items.map((s) => `
|
|
|
|
|
+ <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
|
|
|
|
|
+ <div class="schools-item-name">
|
|
|
|
|
+ <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"></span>
|
|
|
|
|
+ <span>${esc(s.name)}</span>
|
|
|
|
|
+ ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
|
|
|
|
|
+ ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : ''}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="schools-item-meta">
|
|
|
|
|
+ <span>${esc(s.city || province)}</span>
|
|
|
|
|
+ <span>${esc(opsText(s))}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`).join('');
|
|
|
|
|
+ return `<div class="schools-group">${esc(province)} <em>${onCount}/${items.length}</em></div>${rows}`;
|
|
|
|
|
+ }).join('');
|
|
|
|
|
+
|
|
|
|
|
+ list.querySelectorAll('[data-id]').forEach((el) => {
|
|
|
|
|
+ el.addEventListener('click', () => selectSchool(el.dataset.id, true));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function selectSchool(id, fly) {
|
|
|
|
|
+ const school = schools.find((s) => s.id === id);
|
|
|
|
|
+ if (!school) return;
|
|
|
|
|
+ selectedId = id;
|
|
|
|
|
+ renderList({ enabledCount: schools.filter((s) => s.enabled && s.lng != null).length });
|
|
|
|
|
+ if (chart && school.lng != null) {
|
|
|
|
|
+ if (fly) chart.setOption({ geo: { center: [school.lng, school.lat], zoom: 8 } });
|
|
|
|
|
+ chart.dispatchAction({ type: 'highlight', seriesId: 'on', name: school.name });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function bind() {
|
|
|
|
|
+ const reset = $('btnSchoolsReset');
|
|
|
|
|
+ if (reset) {
|
|
|
|
|
+ reset.addEventListener('click', () => {
|
|
|
|
|
+ if (chart) chart.setOption({ geo: { center: CENTER, zoom: ZOOM } });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ window.addEventListener('resize', () => { if (chart) chart.resize(); });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function load() {
|
|
|
|
|
+ if (loading) return;
|
|
|
|
|
+ loading = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [geoRes, listRes] = await Promise.all([
|
|
|
|
|
+ fetch('/vendor/china-map.json'),
|
|
|
|
|
+ fetch('/api/schools'),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ if (!geoRes.ok) throw new Error('底图加载失败');
|
|
|
|
|
+ const geo = await geoRes.json();
|
|
|
|
|
+ const data = await listRes.json();
|
|
|
|
|
+ echarts.registerMap('china', geo);
|
|
|
|
|
+ schools = data.schools || [];
|
|
|
|
|
+ renderList(data);
|
|
|
|
|
+ renderMap(true);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ const box = $('schoolsMap');
|
|
|
|
|
+ if (box) box.innerHTML = `<div class="empty-hint ops-empty"><p>地图加载失败</p><small>${esc(e.message || e)}</small></div>`;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ window.shanSchoolsInit = function () {
|
|
|
|
|
+ if (!inited) {
|
|
|
|
|
+ inited = true;
|
|
|
|
|
+ bind();
|
|
|
|
|
+ load();
|
|
|
|
|
+ } else if (chart) {
|
|
|
|
|
+ chart.resize();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+})();
|