schools.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. 'use strict';
  2. // 校区地图:中国地图 + 学校点位(启用=涟漪点亮,停用/未定位=灰点)+ 右侧列表。
  3. // 数据:GET /api/schools(JSON 种子 + 运行时合并);底图:/vendor/china-map.json
  4. (function () {
  5. const $ = (id) => document.getElementById(id);
  6. const CENTER = [104.5, 36];
  7. const ZOOM = 1.55;
  8. let chart = null;
  9. let schools = [];
  10. let inited = false;
  11. let loading = false;
  12. let selectedId = null;
  13. function esc(value) {
  14. const div = document.createElement('div');
  15. div.textContent = String(value == null ? '' : value);
  16. return div.innerHTML;
  17. }
  18. function fmtDate(ymd) {
  19. const s = String(ymd || '');
  20. return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}/${Number(s.slice(6, 8))}` : '';
  21. }
  22. function opsText(school) {
  23. if (!school.ops) return '运营数据不可用';
  24. if (!school.ops.shopCount) return '未关联运营数据';
  25. return `${school.ops.shopCount} 店${school.ops.lastDate ? ' · 最近 ' + fmtDate(school.ops.lastDate) : ''}`;
  26. }
  27. function toPoint(school) {
  28. return {
  29. name: school.name,
  30. value: [school.lng, school.lat],
  31. schoolId: school.id,
  32. };
  33. }
  34. function buildOption() {
  35. const on = schools.filter((s) => s.enabled && s.lng != null && s.lat != null);
  36. const off = schools.filter((s) => !(s.enabled && s.lng != null && s.lat != null));
  37. return {
  38. tooltip: {
  39. trigger: 'item',
  40. backgroundColor: 'rgba(255,255,255,.96)',
  41. borderColor: '#e8e2db',
  42. borderWidth: 1,
  43. padding: [8, 10],
  44. textStyle: { color: '#3f3a35', fontSize: 12 },
  45. formatter(params) {
  46. const school = schools.find((s) => s.id === params.data.schoolId);
  47. if (!school) return params.name;
  48. const status = school.enabled ? '已启用' : '已停用';
  49. const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
  50. 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>`;
  51. },
  52. },
  53. geo: {
  54. map: 'china',
  55. roam: true,
  56. center: CENTER,
  57. zoom: ZOOM,
  58. scaleLimit: { min: 1, max: 12 },
  59. itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
  60. emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: false } },
  61. select: { disabled: true },
  62. },
  63. series: [
  64. {
  65. id: 'on',
  66. type: 'effectScatter',
  67. coordinateSystem: 'geo',
  68. data: on.map(toPoint),
  69. symbolSize: 11,
  70. showEffectOn: 'render',
  71. rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
  72. itemStyle: { color: '#ff6b35', shadowBlur: 8, shadowColor: 'rgba(255,107,53,.45)' },
  73. label: {
  74. show: true,
  75. position: 'right',
  76. distance: 6,
  77. formatter: (p) => p.name,
  78. fontSize: 11,
  79. color: '#5b534c',
  80. },
  81. emphasis: { scale: 1.4 },
  82. zlevel: 2,
  83. },
  84. {
  85. id: 'off',
  86. type: 'scatter',
  87. coordinateSystem: 'geo',
  88. data: off.map(toPoint),
  89. symbolSize: 6,
  90. itemStyle: { color: '#b8b2ad' },
  91. label: { show: false },
  92. zlevel: 1,
  93. },
  94. ],
  95. };
  96. }
  97. function renderMap(force) {
  98. const box = $('schoolsMap');
  99. if (!box) return;
  100. if (!chart) chart = echarts.init(box);
  101. chart.setOption(buildOption(), !!force);
  102. }
  103. function renderList(data) {
  104. const list = $('schoolsList');
  105. const count = $('schoolsCount');
  106. if (!list) return;
  107. const enabled = data.enabledCount || 0;
  108. if (count) count.textContent = `${enabled} / ${schools.length} 已启用`;
  109. if (!schools.length) {
  110. list.innerHTML = '<div class="empty-hint ops-empty" style="min-height:220px"><p>还没有学校</p><small>内置学校缺失,请检查 server/schools.seed.json</small></div>';
  111. return;
  112. }
  113. const groups = new Map();
  114. for (const school of schools) {
  115. const key = school.province || '未标注省份';
  116. if (!groups.has(key)) groups.set(key, []);
  117. groups.get(key).push(school);
  118. }
  119. list.innerHTML = [...groups.entries()].map(([province, items]) => {
  120. const onCount = items.filter((s) => s.enabled && s.lng != null).length;
  121. const rows = items.map((s) => `
  122. <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
  123. <div class="schools-item-name">
  124. <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"></span>
  125. <span>${esc(s.name)}</span>
  126. ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
  127. ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : ''}
  128. </div>
  129. <div class="schools-item-meta">
  130. <span>${esc(s.city || province)}</span>
  131. <span>${esc(opsText(s))}</span>
  132. </div>
  133. </div>`).join('');
  134. return `<div class="schools-group">${esc(province)} <em>${onCount}/${items.length}</em></div>${rows}`;
  135. }).join('');
  136. list.querySelectorAll('[data-id]').forEach((el) => {
  137. el.addEventListener('click', () => selectSchool(el.dataset.id, true));
  138. });
  139. }
  140. function selectSchool(id, fly) {
  141. const school = schools.find((s) => s.id === id);
  142. if (!school) return;
  143. selectedId = id;
  144. renderList({ enabledCount: schools.filter((s) => s.enabled && s.lng != null).length });
  145. if (chart && school.lng != null) {
  146. if (fly) chart.setOption({ geo: { center: [school.lng, school.lat], zoom: 8 } });
  147. chart.dispatchAction({ type: 'highlight', seriesId: 'on', name: school.name });
  148. }
  149. }
  150. function bind() {
  151. const reset = $('btnSchoolsReset');
  152. if (reset) {
  153. reset.addEventListener('click', () => {
  154. if (chart) chart.setOption({ geo: { center: CENTER, zoom: ZOOM } });
  155. });
  156. }
  157. window.addEventListener('resize', () => { if (chart) chart.resize(); });
  158. }
  159. async function load() {
  160. if (loading) return;
  161. loading = true;
  162. try {
  163. const [geoRes, listRes] = await Promise.all([
  164. fetch('/vendor/china-map.json'),
  165. fetch('/api/schools'),
  166. ]);
  167. if (!geoRes.ok) throw new Error('底图加载失败');
  168. const geo = await geoRes.json();
  169. const data = await listRes.json();
  170. echarts.registerMap('china', geo);
  171. schools = data.schools || [];
  172. renderList(data);
  173. renderMap(true);
  174. } catch (e) {
  175. const box = $('schoolsMap');
  176. if (box) box.innerHTML = `<div class="empty-hint ops-empty"><p>地图加载失败</p><small>${esc(e.message || e)}</small></div>`;
  177. } finally {
  178. loading = false;
  179. }
  180. }
  181. window.shanSchoolsInit = function () {
  182. if (!inited) {
  183. inited = true;
  184. bind();
  185. load();
  186. } else if (chart) {
  187. chart.resize();
  188. }
  189. };
  190. })();