| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- '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 cityPoints = [];
- let geoProvinceNames = [];
- let cityLabelsVisible = false;
- 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 shortProvince(name) {
- const raw = String(name || '');
- const short = raw
- .replace(/特别行政区$/, '')
- .replace(/(壮族|回族|维吾尔)?自治区$/, '')
- .replace(/省$/, '')
- .replace(/市$/, '');
- return short || raw;
- }
- /** 门店数越多颜色越深,用来区分学校的经营体量 */
- function shopColor(count) {
- const n = Number(count) || 0;
- if (n >= 10) return '#e8501f';
- if (n >= 4) return '#ff8a5c';
- if (n >= 1) return '#ffb894';
- return '#d8d1c9';
- }
- /** 每省已点亮(启用且已定位)的学校数 */
- function provinceCounts() {
- const byShort = new Map();
- for (const full of geoProvinceNames) byShort.set(shortProvince(full), full);
- const counts = new Map();
- for (const school of schools) {
- if (!(school.enabled && school.lng != null)) continue;
- const full = byShort.get(shortProvince(school.province)) || school.province;
- if (!full) continue;
- counts.set(full, (counts.get(full) || 0) + 1);
- }
- return counts;
- }
- /** 省份按点亮学校数分档着色(比学校点更浅,避免压过点位) */
- function provinceColor(count) {
- const n = Number(count) || 0;
- if (n >= 10) return '#ef8455';
- if (n >= 4) return '#f4a684';
- if (n >= 2) return '#f8c9b2';
- if (n >= 1) return '#f9dccc';
- return '#f7f5f2';
- }
- function buildCityPoints(data) {
- const points = [];
- for (const info of Object.values(data || {})) {
- const cities = (info && info.cities) || {};
- for (const [name, coord] of Object.entries(cities)) {
- if (Array.isArray(coord) && coord.length === 2) points.push({ name, value: coord });
- }
- }
- return points;
- }
- function toPoint(school) {
- return {
- name: school.name,
- value: [school.lng, school.lat],
- schoolId: school.id,
- itemStyle: { color: shopColor(school.ops && school.ops.shopCount) },
- };
- }
- 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 = params.data && schools.find((s) => s.id === params.data.schoolId);
- if (!school) {
- const count = provinceCounts().get(params.name) || 0;
- return `<b>${esc(params.name)}</b><br>点亮学校:${count} 所`;
- }
- const status = school.enabled ? '已启用' : '已停用';
- const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
- const origin = school.discovered ? '导入数据自动发现' : (school.seed ? '内置学校' : '手动创建');
- const precision = school.lng == null ? '' : (school.approx ? '(大致位置,按高校坐标估算)' : '(已定位)');
- 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>`;
- },
- },
- geo: {
- map: 'china',
- roam: true,
- center: CENTER,
- zoom: ZOOM,
- scaleLimit: { min: 1, max: 12 },
- itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
- regions: [...provinceCounts().entries()].map(([name, count]) => ({
- name,
- itemStyle: { areaColor: provinceColor(count) },
- })),
- label: { show: true, color: '#8f8478', fontSize: 10, formatter: (p) => shortProvince(p.name) },
- emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: true, color: '#5b534c', fontSize: 10 } },
- select: { disabled: true },
- },
- series: [
- {
- id: 'cities',
- type: 'scatter',
- coordinateSystem: 'geo',
- data: cityPoints,
- symbolSize: 2,
- itemStyle: { color: '#d5cdc4' },
- label: {
- show: cityLabelsVisible,
- position: 'right',
- distance: 2,
- fontSize: 9,
- color: '#a39a91',
- formatter: (p) => p.name,
- },
- labelLayout: { hideOverlap: true },
- silent: true,
- zlevel: 0,
- },
- {
- id: 'on',
- type: 'effectScatter',
- coordinateSystem: 'geo',
- data: on.map(toPoint),
- symbolSize: 11,
- showEffectOn: 'render',
- rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
- itemStyle: { shadowBlur: 8, shadowColor: 'rgba(255,107,53,.35)' },
- label: {
- show: true,
- position: 'right',
- distance: 6,
- formatter: (p) => p.name,
- fontSize: 11,
- color: '#5b534c',
- },
- labelLayout: { hideOverlap: true },
- 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 renderLegend() {
- const box = $('schoolsLegend');
- if (!box) return;
- const hasOps = schools.some((s) => s.ops && s.ops.shopCount > 0);
- if (!hasOps) {
- box.innerHTML = '';
- return;
- }
- const litProvinces = provinceCounts();
- const maxLit = Math.max(0, ...litProvinces.values());
- box.innerHTML = '<span class="schools-legend-title">每省点亮学校</span>'
- + '<span><i style="background:#f9dccc"></i>1</span>'
- + '<span><i style="background:#f8c9b2"></i>2-3</span>'
- + '<span><i style="background:#f4a684"></i>4-9</span>'
- + '<span><i style="background:#ef8455"></i>10+</span>'
- + '<span class="schools-legend-sep"></span>'
- + '<span class="schools-legend-title">门店数</span>'
- + '<span><i style="background:#d8d1c9"></i>0</span>'
- + '<span><i style="background:#ffb894"></i>1-3</span>'
- + '<span><i style="background:#ff8a5c"></i>4-9</span>'
- + '<span><i style="background:#e8501f"></i>10+</span>'
- + '<span class="schools-legend-sep"></span>'
- + '<span><i class="hollow"></i>停用 / 未定位</span>';
- }
- function renderMap(force) {
- const box = $('schoolsMap');
- if (!box) return;
- if (!chart) {
- chart = echarts.init(box);
- chart.on('georoam', () => {
- const option = chart.getOption();
- const zoom = (option.geo && option.geo[0] && option.geo[0].zoom) || ZOOM;
- const shouldShow = zoom >= 2;
- if (shouldShow === cityLabelsVisible) return;
- cityLabelsVisible = shouldShow;
- chart.setOption({ series: [{ id: 'cities', label: { show: shouldShow } }] });
- });
- }
- chart.setOption(buildOption(), !!force);
- renderLegend();
- }
- 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'}"${s.enabled && s.lng != null ? ` style="background:${shopColor(s.ops && s.ops.shopCount)}"` : ''}></span>
- <span>${esc(s.name)}</span>
- ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
- ${s.discovered ? '<span class="badge badge-muted">导入</span>' : ''}
- ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : (s.approx ? '<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, cityRes] = await Promise.all([
- fetch('/vendor/china-map.json'),
- fetch('/api/schools'),
- fetch('/vendor/cn-cities.json').catch(() => null),
- ]);
- if (!geoRes.ok) throw new Error('底图加载失败');
- const geo = await geoRes.json();
- const data = await listRes.json();
- echarts.registerMap('china', geo);
- geoProvinceNames = (geo.features || []).map((f) => (f.properties && f.properties.name) || '').filter(Boolean);
- if (cityRes && cityRes.ok) {
- try { cityPoints = buildCityPoints(await cityRes.json()); } catch { cityPoints = []; }
- }
- 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.addEventListener('ops:data-changed', () => {
- if (inited) load();
- });
- window.shanSchoolsInit = function () {
- if (!inited) {
- inited = true;
- bind();
- load();
- } else if (chart) {
- chart.resize();
- }
- };
- })();
|