schools.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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 cityPoints = [];
  11. let cityLabelsVisible = false;
  12. let inited = false;
  13. let loading = false;
  14. let selectedId = null;
  15. function esc(value) {
  16. const div = document.createElement('div');
  17. div.textContent = String(value == null ? '' : value);
  18. return div.innerHTML;
  19. }
  20. function fmtDate(ymd) {
  21. const s = String(ymd || '');
  22. return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}/${Number(s.slice(6, 8))}` : '';
  23. }
  24. function opsText(school) {
  25. if (!school.ops) return '运营数据不可用';
  26. if (!school.ops.shopCount) return '未关联运营数据';
  27. return `${school.ops.shopCount} 店${school.ops.lastDate ? ' · 最近 ' + fmtDate(school.ops.lastDate) : ''}`;
  28. }
  29. function shortProvince(name) {
  30. const raw = String(name || '');
  31. const short = raw
  32. .replace(/特别行政区$/, '')
  33. .replace(/(壮族|回族|维吾尔)?自治区$/, '')
  34. .replace(/省$/, '')
  35. .replace(/市$/, '');
  36. return short || raw;
  37. }
  38. /** 门店数越多颜色越深,用来区分学校的经营体量 */
  39. function shopColor(count) {
  40. const n = Number(count) || 0;
  41. if (n >= 10) return '#e8501f';
  42. if (n >= 4) return '#ff8a5c';
  43. if (n >= 1) return '#ffb894';
  44. return '#d8d1c9';
  45. }
  46. function buildCityPoints(data) {
  47. const points = [];
  48. for (const info of Object.values(data || {})) {
  49. const cities = (info && info.cities) || {};
  50. for (const [name, coord] of Object.entries(cities)) {
  51. if (Array.isArray(coord) && coord.length === 2) points.push({ name, value: coord });
  52. }
  53. }
  54. return points;
  55. }
  56. function toPoint(school) {
  57. return {
  58. name: school.name,
  59. value: [school.lng, school.lat],
  60. schoolId: school.id,
  61. itemStyle: { color: shopColor(school.ops && school.ops.shopCount) },
  62. };
  63. }
  64. function buildOption() {
  65. const on = schools.filter((s) => s.enabled && s.lng != null && s.lat != null);
  66. const off = schools.filter((s) => !(s.enabled && s.lng != null && s.lat != null));
  67. return {
  68. tooltip: {
  69. trigger: 'item',
  70. backgroundColor: 'rgba(255,255,255,.96)',
  71. borderColor: '#e8e2db',
  72. borderWidth: 1,
  73. padding: [8, 10],
  74. textStyle: { color: '#3f3a35', fontSize: 12 },
  75. formatter(params) {
  76. const school = schools.find((s) => s.id === params.data.schoolId);
  77. if (!school) return params.name;
  78. const status = school.enabled ? '已启用' : '已停用';
  79. const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
  80. 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>`;
  81. },
  82. },
  83. geo: {
  84. map: 'china',
  85. roam: true,
  86. center: CENTER,
  87. zoom: ZOOM,
  88. scaleLimit: { min: 1, max: 12 },
  89. itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
  90. label: { show: true, color: '#8f8478', fontSize: 10, formatter: (p) => shortProvince(p.name) },
  91. emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: true, color: '#5b534c', fontSize: 10 } },
  92. select: { disabled: true },
  93. },
  94. series: [
  95. {
  96. id: 'cities',
  97. type: 'scatter',
  98. coordinateSystem: 'geo',
  99. data: cityPoints,
  100. symbolSize: 2,
  101. itemStyle: { color: '#d5cdc4' },
  102. label: {
  103. show: cityLabelsVisible,
  104. position: 'right',
  105. distance: 2,
  106. fontSize: 9,
  107. color: '#a39a91',
  108. formatter: (p) => p.name,
  109. },
  110. labelLayout: { hideOverlap: true },
  111. silent: true,
  112. zlevel: 0,
  113. },
  114. {
  115. id: 'on',
  116. type: 'effectScatter',
  117. coordinateSystem: 'geo',
  118. data: on.map(toPoint),
  119. symbolSize: 11,
  120. showEffectOn: 'render',
  121. rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
  122. itemStyle: { shadowBlur: 8, shadowColor: 'rgba(255,107,53,.35)' },
  123. label: {
  124. show: true,
  125. position: 'right',
  126. distance: 6,
  127. formatter: (p) => p.name,
  128. fontSize: 11,
  129. color: '#5b534c',
  130. },
  131. emphasis: { scale: 1.4 },
  132. zlevel: 2,
  133. },
  134. {
  135. id: 'off',
  136. type: 'scatter',
  137. coordinateSystem: 'geo',
  138. data: off.map(toPoint),
  139. symbolSize: 6,
  140. itemStyle: { color: '#b8b2ad' },
  141. label: { show: false },
  142. zlevel: 1,
  143. },
  144. ],
  145. };
  146. }
  147. function renderLegend() {
  148. const box = $('schoolsLegend');
  149. if (!box) return;
  150. const hasOps = schools.some((s) => s.ops && s.ops.shopCount > 0);
  151. if (!hasOps) {
  152. box.innerHTML = '';
  153. return;
  154. }
  155. box.innerHTML = '<span class="schools-legend-title">门店数</span>'
  156. + '<span><i style="background:#d8d1c9"></i>0</span>'
  157. + '<span><i style="background:#ffb894"></i>1-3</span>'
  158. + '<span><i style="background:#ff8a5c"></i>4-9</span>'
  159. + '<span><i style="background:#e8501f"></i>10+</span>'
  160. + '<span class="schools-legend-sep"></span>'
  161. + '<span><i class="hollow"></i>停用 / 未定位</span>';
  162. }
  163. function renderMap(force) {
  164. const box = $('schoolsMap');
  165. if (!box) return;
  166. if (!chart) {
  167. chart = echarts.init(box);
  168. chart.on('georoam', () => {
  169. const option = chart.getOption();
  170. const zoom = (option.geo && option.geo[0] && option.geo[0].zoom) || ZOOM;
  171. const shouldShow = zoom >= 2;
  172. if (shouldShow === cityLabelsVisible) return;
  173. cityLabelsVisible = shouldShow;
  174. chart.setOption({ series: [{ id: 'cities', label: { show: shouldShow } }] });
  175. });
  176. }
  177. chart.setOption(buildOption(), !!force);
  178. renderLegend();
  179. }
  180. function renderList(data) {
  181. const list = $('schoolsList');
  182. const count = $('schoolsCount');
  183. if (!list) return;
  184. const enabled = data.enabledCount || 0;
  185. if (count) count.textContent = `${enabled} / ${schools.length} 已启用`;
  186. if (!schools.length) {
  187. list.innerHTML = '<div class="empty-hint ops-empty" style="min-height:220px"><p>还没有学校</p><small>内置学校缺失,请检查 server/schools.seed.json</small></div>';
  188. return;
  189. }
  190. const groups = new Map();
  191. for (const school of schools) {
  192. const key = school.province || '未标注省份';
  193. if (!groups.has(key)) groups.set(key, []);
  194. groups.get(key).push(school);
  195. }
  196. list.innerHTML = [...groups.entries()].map(([province, items]) => {
  197. const onCount = items.filter((s) => s.enabled && s.lng != null).length;
  198. const rows = items.map((s) => `
  199. <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
  200. <div class="schools-item-name">
  201. <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"${s.enabled && s.lng != null ? ` style="background:${shopColor(s.ops && s.ops.shopCount)}"` : ''}></span>
  202. <span>${esc(s.name)}</span>
  203. ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
  204. ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : ''}
  205. </div>
  206. <div class="schools-item-meta">
  207. <span>${esc(s.city || province)}</span>
  208. <span>${esc(opsText(s))}</span>
  209. </div>
  210. </div>`).join('');
  211. return `<div class="schools-group">${esc(province)} <em>${onCount}/${items.length}</em></div>${rows}`;
  212. }).join('');
  213. list.querySelectorAll('[data-id]').forEach((el) => {
  214. el.addEventListener('click', () => selectSchool(el.dataset.id, true));
  215. });
  216. }
  217. function selectSchool(id, fly) {
  218. const school = schools.find((s) => s.id === id);
  219. if (!school) return;
  220. selectedId = id;
  221. renderList({ enabledCount: schools.filter((s) => s.enabled && s.lng != null).length });
  222. if (chart && school.lng != null) {
  223. if (fly) chart.setOption({ geo: { center: [school.lng, school.lat], zoom: 8 } });
  224. chart.dispatchAction({ type: 'highlight', seriesId: 'on', name: school.name });
  225. }
  226. }
  227. function bind() {
  228. const reset = $('btnSchoolsReset');
  229. if (reset) {
  230. reset.addEventListener('click', () => {
  231. if (chart) chart.setOption({ geo: { center: CENTER, zoom: ZOOM } });
  232. });
  233. }
  234. window.addEventListener('resize', () => { if (chart) chart.resize(); });
  235. }
  236. async function load() {
  237. if (loading) return;
  238. loading = true;
  239. try {
  240. const [geoRes, listRes, cityRes] = await Promise.all([
  241. fetch('/vendor/china-map.json'),
  242. fetch('/api/schools'),
  243. fetch('/vendor/cn-cities.json').catch(() => null),
  244. ]);
  245. if (!geoRes.ok) throw new Error('底图加载失败');
  246. const geo = await geoRes.json();
  247. const data = await listRes.json();
  248. echarts.registerMap('china', geo);
  249. if (cityRes && cityRes.ok) {
  250. try { cityPoints = buildCityPoints(await cityRes.json()); } catch { cityPoints = []; }
  251. }
  252. schools = data.schools || [];
  253. renderList(data);
  254. renderMap(true);
  255. } catch (e) {
  256. const box = $('schoolsMap');
  257. if (box) box.innerHTML = `<div class="empty-hint ops-empty"><p>地图加载失败</p><small>${esc(e.message || e)}</small></div>`;
  258. } finally {
  259. loading = false;
  260. }
  261. }
  262. // 运营数据变更后,学校的关联门店数/最近日期会变,需要重新拉取
  263. window.addEventListener('ops:data-changed', () => {
  264. if (inited) load();
  265. });
  266. window.shanSchoolsInit = function () {
  267. if (!inited) {
  268. inited = true;
  269. bind();
  270. load();
  271. } else if (chart) {
  272. chart.resize();
  273. }
  274. };
  275. })();