schools.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. const origin = school.discovered ? '导入数据自动发现' : (school.seed ? '内置学校' : '手动创建');
  107. const precision = school.lng == null ? '' : (school.approx ? '(大致位置,按高校坐标估算)' : '(已定位)');
  108. return `<b>${esc(school.name)}</b><br>${esc(school.province || '—')}${school.city ? ' · ' + esc(school.city) : ''}<br>${status} · ${opsText(school)}<br>${origin}${precision}<br><span style="color:#9a938c">${point}</span>`;
  109. },
  110. },
  111. geo: {
  112. map: 'china',
  113. roam: true,
  114. center: CENTER,
  115. zoom: ZOOM,
  116. scaleLimit: { min: 1, max: 12 },
  117. itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
  118. regions: [...provinceCounts().entries()].map(([name, count]) => ({
  119. name,
  120. itemStyle: { areaColor: provinceColor(count) },
  121. })),
  122. label: { show: true, color: '#8f8478', fontSize: 10, formatter: (p) => shortProvince(p.name) },
  123. emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: true, color: '#5b534c', fontSize: 10 } },
  124. select: { disabled: true },
  125. },
  126. series: [
  127. {
  128. id: 'cities',
  129. type: 'scatter',
  130. coordinateSystem: 'geo',
  131. data: cityPoints,
  132. symbolSize: 2,
  133. itemStyle: { color: '#d5cdc4' },
  134. label: {
  135. show: cityLabelsVisible,
  136. position: 'right',
  137. distance: 2,
  138. fontSize: 9,
  139. color: '#a39a91',
  140. formatter: (p) => p.name,
  141. },
  142. labelLayout: { hideOverlap: true },
  143. silent: true,
  144. zlevel: 0,
  145. },
  146. {
  147. id: 'on',
  148. type: 'effectScatter',
  149. coordinateSystem: 'geo',
  150. data: on.map(toPoint),
  151. symbolSize: 11,
  152. showEffectOn: 'render',
  153. rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
  154. itemStyle: { shadowBlur: 8, shadowColor: 'rgba(255,107,53,.35)' },
  155. label: {
  156. show: true,
  157. position: 'right',
  158. distance: 6,
  159. formatter: (p) => p.name,
  160. fontSize: 11,
  161. color: '#5b534c',
  162. },
  163. labelLayout: { hideOverlap: true },
  164. emphasis: { scale: 1.4 },
  165. zlevel: 2,
  166. },
  167. {
  168. id: 'off',
  169. type: 'scatter',
  170. coordinateSystem: 'geo',
  171. data: off.map(toPoint),
  172. symbolSize: 6,
  173. itemStyle: { color: '#b8b2ad' },
  174. label: { show: false },
  175. zlevel: 1,
  176. },
  177. ],
  178. };
  179. }
  180. function renderLegend() {
  181. const box = $('schoolsLegend');
  182. if (!box) return;
  183. const hasOps = schools.some((s) => s.ops && s.ops.shopCount > 0);
  184. if (!hasOps) {
  185. box.innerHTML = '';
  186. return;
  187. }
  188. const litProvinces = provinceCounts();
  189. const maxLit = Math.max(0, ...litProvinces.values());
  190. box.innerHTML = '<span class="schools-legend-title">每省点亮学校</span>'
  191. + '<span><i style="background:#f9dccc"></i>1</span>'
  192. + '<span><i style="background:#f8c9b2"></i>2-3</span>'
  193. + '<span><i style="background:#f4a684"></i>4-9</span>'
  194. + '<span><i style="background:#ef8455"></i>10+</span>'
  195. + '<span class="schools-legend-sep"></span>'
  196. + '<span class="schools-legend-title">门店数</span>'
  197. + '<span><i style="background:#d8d1c9"></i>0</span>'
  198. + '<span><i style="background:#ffb894"></i>1-3</span>'
  199. + '<span><i style="background:#ff8a5c"></i>4-9</span>'
  200. + '<span><i style="background:#e8501f"></i>10+</span>'
  201. + '<span class="schools-legend-sep"></span>'
  202. + '<span><i class="hollow"></i>停用 / 未定位</span>';
  203. }
  204. function renderMap(force) {
  205. const box = $('schoolsMap');
  206. if (!box) return;
  207. if (!chart) {
  208. chart = echarts.init(box);
  209. chart.on('georoam', () => {
  210. const option = chart.getOption();
  211. const zoom = (option.geo && option.geo[0] && option.geo[0].zoom) || ZOOM;
  212. const shouldShow = zoom >= 2;
  213. if (shouldShow === cityLabelsVisible) return;
  214. cityLabelsVisible = shouldShow;
  215. chart.setOption({ series: [{ id: 'cities', label: { show: shouldShow } }] });
  216. });
  217. }
  218. chart.setOption(buildOption(), !!force);
  219. renderLegend();
  220. }
  221. function renderList(data) {
  222. const list = $('schoolsList');
  223. const count = $('schoolsCount');
  224. if (!list) return;
  225. const enabled = data.enabledCount || 0;
  226. if (count) count.textContent = `${enabled} / ${schools.length} 已启用`;
  227. if (!schools.length) {
  228. list.innerHTML = '<div class="empty-hint ops-empty" style="min-height:220px"><p>还没有学校</p><small>内置学校缺失,请检查 server/schools.seed.json</small></div>';
  229. return;
  230. }
  231. const groups = new Map();
  232. for (const school of schools) {
  233. const key = school.province || '未标注省份';
  234. if (!groups.has(key)) groups.set(key, []);
  235. groups.get(key).push(school);
  236. }
  237. list.innerHTML = [...groups.entries()].map(([province, items]) => {
  238. const onCount = items.filter((s) => s.enabled && s.lng != null).length;
  239. const rows = items.map((s) => `
  240. <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
  241. <div class="schools-item-name">
  242. <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"${s.enabled && s.lng != null ? ` style="background:${shopColor(s.ops && s.ops.shopCount)}"` : ''}></span>
  243. <span>${esc(s.name)}</span>
  244. ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
  245. ${s.discovered ? '<span class="badge badge-muted">导入</span>' : ''}
  246. ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : (s.approx ? '<span class="badge badge-warn">大致位置</span>' : '')}
  247. </div>
  248. <div class="schools-item-meta">
  249. <span>${esc(s.city || province)}</span>
  250. <span>${esc(opsText(s))}</span>
  251. </div>
  252. </div>`).join('');
  253. return `<div class="schools-group">${esc(province)} <em>${onCount}/${items.length}</em></div>${rows}`;
  254. }).join('');
  255. list.querySelectorAll('[data-id]').forEach((el) => {
  256. el.addEventListener('click', () => selectSchool(el.dataset.id, true));
  257. });
  258. }
  259. function selectSchool(id, fly) {
  260. const school = schools.find((s) => s.id === id);
  261. if (!school) return;
  262. selectedId = id;
  263. renderList({ enabledCount: schools.filter((s) => s.enabled && s.lng != null).length });
  264. if (chart && school.lng != null) {
  265. if (fly) chart.setOption({ geo: { center: [school.lng, school.lat], zoom: 8 } });
  266. chart.dispatchAction({ type: 'highlight', seriesId: 'on', name: school.name });
  267. }
  268. }
  269. function bind() {
  270. const reset = $('btnSchoolsReset');
  271. if (reset) {
  272. reset.addEventListener('click', () => {
  273. if (chart) chart.setOption({ geo: { center: CENTER, zoom: ZOOM } });
  274. });
  275. }
  276. window.addEventListener('resize', () => { if (chart) chart.resize(); });
  277. }
  278. async function load() {
  279. if (loading) return;
  280. loading = true;
  281. try {
  282. const [geoRes, listRes, cityRes] = await Promise.all([
  283. fetch('/vendor/china-map.json'),
  284. fetch('/api/schools'),
  285. fetch('/vendor/cn-cities.json').catch(() => null),
  286. ]);
  287. if (!geoRes.ok) throw new Error('底图加载失败');
  288. const geo = await geoRes.json();
  289. const data = await listRes.json();
  290. echarts.registerMap('china', geo);
  291. geoProvinceNames = (geo.features || []).map((f) => (f.properties && f.properties.name) || '').filter(Boolean);
  292. if (cityRes && cityRes.ok) {
  293. try { cityPoints = buildCityPoints(await cityRes.json()); } catch { cityPoints = []; }
  294. }
  295. schools = data.schools || [];
  296. renderList(data);
  297. renderMap(true);
  298. } catch (e) {
  299. const box = $('schoolsMap');
  300. if (box) box.innerHTML = `<div class="empty-hint ops-empty"><p>地图加载失败</p><small>${esc(e.message || e)}</small></div>`;
  301. } finally {
  302. loading = false;
  303. }
  304. }
  305. // 运营数据变更后,学校的关联门店数/最近日期会变,需要重新拉取
  306. window.addEventListener('ops:data-changed', () => {
  307. if (inited) load();
  308. });
  309. window.shanSchoolsInit = function () {
  310. if (!inited) {
  311. inited = true;
  312. bind();
  313. load();
  314. } else if (chart) {
  315. chart.resize();
  316. }
  317. };
  318. })();