schools.js 12 KB

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