Bladeren bron

feat: 校区地图首个增量(内置浙江工商桐庐校区 + 中国地图点亮)

- 新增一级菜单「校区地图」:ECharts geo + effectScatter,本地 569KB 省级底图
- 学校数据 JSON 静态存储:server/schools.seed.json(内置)+ data/schools.json(运行时覆盖)
- 内置「浙江工商大学桐庐校区」GCJ-02 [119.744166, 29.830417],含 3 个别名
- GET /api/schools:合并种子与运行时,附带运营关联(门店数 / 最近数据日期)
- 列表按省份分组、启用数统计、点击联动地图飞过去
- 测试:scripts/test-schools.js 7 条(合并/覆盖/越界/损坏兜底);
  smoke 新增只读断言(110/110)
liangzhao 2 weken geleden
bovenliggende
commit
c4427891e0
12 gewijzigde bestanden met toevoegingen van 551 en 2 verwijderingen
  1. 2 1
      package.json
  2. 2 0
      public/app.js
  3. 24 0
      public/index.html
  4. 207 0
      public/schools.js
  5. 23 0
      public/style.css
  6. 98 0
      schools-store.js
  7. 8 1
      scripts/smoke.js
  8. 108 0
      scripts/test-schools.js
  9. 1 0
      server/routes/index.js
  10. 53 0
      server/routes/schools.js
  11. 4 0
      server/routes/static.js
  12. 21 0
      server/schools.seed.json

+ 2 - 1
package.json

@@ -9,7 +9,8 @@
     "test:ops": "node scripts/test-ops-import.js",
     "db:init": "node scripts/db-init.js",
     "db:export": "node scripts/db-export.js",
-    "db:import": "node scripts/db-import.js"
+    "db:import": "node scripts/db-import.js",
+    "test:schools": "node scripts/test-schools.js"
   },
   "dependencies": {
     "jpeg-js": "^0.4.4",

+ 2 - 0
public/app.js

@@ -35,6 +35,7 @@
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
     ops: document.getElementById('viewOps'),
+    schools: document.getElementById('viewSchools'),
     lake: document.getElementById('viewLake'),
     analysis: document.getElementById('viewAnalysis'),
     ask: document.getElementById('viewAsk'),
@@ -401,6 +402,7 @@
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
     if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
+    if (name === 'schools' && window.shanSchoolsInit) window.shanSchoolsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表
     if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
     if (name === 'ask' && window.shanAskInit) window.shanAskInit();

+ 24 - 0
public/index.html

@@ -40,6 +40,7 @@
 
     <div class="nav-section-label">经营分析</div>
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
+    <button class="nav-item" data-view="schools"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="10" r="3"/><path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z"/></svg></span><span>校区地图</span></button>
     <button class="nav-item" data-view="ops"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M7 20V9M12 20V4M17 20v-7"/></svg></span><span>店铺运营</span></button>
     <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
     <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span><span class="nav-beta">Beta</span></button>
@@ -327,6 +328,28 @@
       </main>
     </div>
 
+    <!-- ==================== 校区地图 ==================== -->
+    <div id="viewSchools" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>校区地图</h1>
+            <div class="sub">已启用的学校点亮在中国地图上,点击可查看关联的运营数据</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnSchoolsReset">回到全国</button>
+          </div>
+        </div>
+        <div class="schools-layout">
+          <div class="schools-map-card"><div id="schoolsMap" class="schools-map"></div></div>
+          <aside class="schools-side">
+            <div class="schools-side-head"><b>学校</b><span id="schoolsCount"></span></div>
+            <div id="schoolsList" class="schools-list"></div>
+          </aside>
+        </div>
+      </main>
+    </div>
+
     <!-- ==================== 店铺运营 ==================== -->
     <div id="viewOps" class="view">
       <main class="content">
@@ -1130,6 +1153,7 @@
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
 <script src="/ops.js"></script>
+<script src="/schools.js"></script>
 <script src="/ask.js"></script>
 <script src="/app.js"></script>
 </body>

+ 207 - 0
public/schools.js

@@ -0,0 +1,207 @@
+'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 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 toPoint(school) {
+    return {
+      name: school.name,
+      value: [school.lng, school.lat],
+      schoolId: school.id,
+    };
+  }
+
+  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 = schools.find((s) => s.id === params.data.schoolId);
+          if (!school) return params.name;
+          const status = school.enabled ? '已启用' : '已停用';
+          const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
+          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>`;
+        },
+      },
+      geo: {
+        map: 'china',
+        roam: true,
+        center: CENTER,
+        zoom: ZOOM,
+        scaleLimit: { min: 1, max: 12 },
+        itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
+        emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: false } },
+        select: { disabled: true },
+      },
+      series: [
+        {
+          id: 'on',
+          type: 'effectScatter',
+          coordinateSystem: 'geo',
+          data: on.map(toPoint),
+          symbolSize: 11,
+          showEffectOn: 'render',
+          rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
+          itemStyle: { color: '#ff6b35', shadowBlur: 8, shadowColor: 'rgba(255,107,53,.45)' },
+          label: {
+            show: true,
+            position: 'right',
+            distance: 6,
+            formatter: (p) => p.name,
+            fontSize: 11,
+            color: '#5b534c',
+          },
+          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 renderMap(force) {
+    const box = $('schoolsMap');
+    if (!box) return;
+    if (!chart) chart = echarts.init(box);
+    chart.setOption(buildOption(), !!force);
+  }
+
+  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'}"></span>
+            <span>${esc(s.name)}</span>
+            ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
+            ${s.lng == null ? '<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] = await Promise.all([
+        fetch('/vendor/china-map.json'),
+        fetch('/api/schools'),
+      ]);
+      if (!geoRes.ok) throw new Error('底图加载失败');
+      const geo = await geoRes.json();
+      const data = await listRes.json();
+      echarts.registerMap('china', geo);
+      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.shanSchoolsInit = function () {
+    if (!inited) {
+      inited = true;
+      bind();
+      load();
+    } else if (chart) {
+      chart.resize();
+    }
+  };
+})();

+ 23 - 0
public/style.css

@@ -1261,3 +1261,26 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ops-file-del:hover { color: var(--text); background: var(--border-light); }
 .ops-check { display: flex; align-items: center; gap: 7px; margin-top: 14px; font-size: 12.5px; color: var(--text-2); cursor: pointer; }
 .ops-check input { accent-color: var(--accent); }
+
+/* ── 校区地图 ─────────────────────────────────────────── */
+.schools-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
+.schools-map-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 6px; }
+.schools-map { width: 100%; height: clamp(420px, 68vh, 640px); }
+.schools-side { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
+.schools-side-head { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--border-light); font-size: 13px; color: var(--text); }
+.schools-side-head span { font-size: 11.5px; color: var(--text-3); }
+.schools-list { max-height: clamp(420px, 68vh, 640px); overflow: auto; }
+.schools-group { padding: 10px 14px 4px; font-size: 11.5px; color: var(--text-3); background: #fbfaf8; }
+.schools-group em { font-style: normal; margin-left: 6px; opacity: .8; }
+.schools-item { padding: 10px 14px; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: background .15s; }
+.schools-item:hover { background: #faf9f7; }
+.schools-item.on { background: #fff6f1; box-shadow: inset 3px 0 0 var(--accent); }
+.schools-item-name { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--text); }
+.schools-item-name .badge { font-weight: 500; }
+.schools-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(255, 107, 53, .16); }
+.schools-dot.off { background: #b8b2ad; box-shadow: none; }
+.schools-item-meta { margin-top: 4px; padding-left: 15px; display: flex; gap: 10px; font-size: 11.5px; color: var(--text-3); }
+@media (max-width: 1100px) {
+  .schools-layout { grid-template-columns: minmax(0, 1fr); }
+  .schools-list { max-height: 340px; }
+}

+ 98 - 0
schools-store.js

@@ -0,0 +1,98 @@
+'use strict';
+
+/**
+ * 校区地图 · 学校数据(JSON 静态存储)
+ * ----------------------------------------------------------------------------
+ * 两份文件合并成一份视图:
+ *   server/schools.seed.json  随版本发布的内置学校(只读,可被运行时覆盖 enabled/坐标)
+ *   data/schools.json         运行时新增/修改(gitignore;由 SCHOOLS_FILE 可覆盖,测试用)
+ *
+ * 合并规则:以 name 为唯一键,运行时同名字段覆盖种子;种子的 seed 标记始终保留。
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT_DIR = __dirname;
+const SEED_FILE = process.env.SCHOOLS_SEED_FILE
+  ? path.resolve(process.env.SCHOOLS_SEED_FILE)
+  : path.join(ROOT_DIR, 'server', 'schools.seed.json');
+const RUNTIME_FILE = process.env.SCHOOLS_FILE
+  ? path.resolve(process.env.SCHOOLS_FILE)
+  : path.join(ROOT_DIR, 'data', 'schools.json');
+
+function readJson(file, fallback) {
+  try {
+    return JSON.parse(fs.readFileSync(file, 'utf8'));
+  } catch (e) {
+    if (e.code !== 'ENOENT') console.warn(`[schools] 读取失败 ${file}: ${e.message}`);
+    return fallback;
+  }
+}
+
+function normalizeEntry(raw) {
+  if (!raw || !raw.name) return null;
+  const name = String(raw.name).trim();
+  if (!name) return null;
+  const lng = Number(raw.lng);
+  const lat = Number(raw.lat);
+  const hasPoint = Number.isFinite(lng) && Number.isFinite(lat)
+    && lng > 73 && lng < 136 && lat > 3 && lat < 54;
+  return {
+    id: raw.id || `sch_${Buffer.from(name).toString('base64url').slice(0, 10)}`,
+    name,
+    aliases: Array.isArray(raw.aliases) ? raw.aliases.map(String) : [],
+    province: raw.province || '',
+    city: raw.city || '',
+    county: raw.county || '',
+    lng: hasPoint ? lng : null,
+    lat: hasPoint ? lat : null,
+    source: raw.source || 'local',
+    seed: raw.seed === true,
+    enabled: raw.enabled !== false,
+    created_at: raw.created_at || '',
+    updated_at: raw.updated_at || '',
+  };
+}
+
+/** 读原始条目(不补默认值):合并时才能区分「显式给了空值」和「没给」 */
+function readRawList(file) {
+  const data = readJson(file, null);
+  if (!data || !Array.isArray(data.schools)) return [];
+  return data.schools.filter((item) => item && typeof item === 'object' && String(item.name || '').trim());
+}
+
+/** 合并后的学校列表(种子 + 运行时),按省份、名称排序 */
+function loadSchools() {
+  const merged = new Map();
+  for (const raw of readRawList(SEED_FILE)) {
+    const name = String(raw.name).trim();
+    merged.set(name, { ...raw, name, seed: true });
+  }
+  for (const raw of readRawList(RUNTIME_FILE)) {
+    const name = String(raw.name).trim();
+    const base = merged.get(name);
+    merged.set(name, {
+      // 只覆盖运行时显式给出的字段
+      ...(base || {}),
+      ...raw,
+      name,
+      // 种子标记不可被运行时文件伪造/清除
+      seed: !!(base && base.seed),
+    });
+  }
+  return [...merged.values()].map(normalizeEntry).filter(Boolean).sort((a, b) => {
+    const p = String(a.province).localeCompare(String(b.province), 'zh-Hans-CN');
+    return p !== 0 ? p : String(a.name).localeCompare(String(b.name), 'zh-Hans-CN');
+  });
+}
+
+function runtimeFile() {
+  return RUNTIME_FILE;
+}
+
+function seedFile() {
+  return SEED_FILE;
+}
+
+module.exports = { loadSchools, normalizeEntry, runtimeFile, seedFile };

+ 8 - 1
scripts/smoke.js

@@ -129,7 +129,7 @@ async function main() {
     rootDir: tmpMainWorkspace,
   }));
   const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
-    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig, NAPOS_CDP_PORT: '1' },
+    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig, NAPOS_CDP_PORT: '1', SCHOOLS_FILE: path.join(tmpMain, 'schools.json') },
     stdio: ['ignore', 'pipe', 'pipe'],
   });
   let bootLog = '';
@@ -352,6 +352,13 @@ async function main() {
       const r = await request(port, 'GET', '/api/ops/uploads');
       expect(r.status === 200 && Array.isArray(r.json().uploads) && r.json().uploads.length === 0, `status=${r.status}`);
     });
+    await check('GET /api/schools(内置学校可见)', async () => {
+      const r = await request(port, 'GET', '/api/schools');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.schools), `status=${r.status}`);
+      expect(d.schools.some((s) => s.name === '浙江工商大学桐庐校区'), '缺少内置学校');
+      expect(d.schools.every((s) => 'ops' in s), '缺少关联字段');
+    });
     await check('GET /api/ops/rows(空库 → 空矩阵)', async () => {
       const r = await request(port, 'GET', '/api/ops/rows');
       const d = r.json();

+ 108 - 0
scripts/test-schools.js

@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+'use strict';
+
+// 学校数据(JSON 种子 + 运行时)单元测试
+// 用法:npm run test:schools
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const results = [];
+function expect(cond, msg) {
+  if (!cond) throw new Error(msg);
+}
+async function check(name, fn) {
+  try {
+    await fn();
+    results.push({ name, ok: true });
+  } catch (e) {
+    results.push({ name, ok: false, error: e.message || String(e) });
+  }
+}
+
+async function main() {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'schools-test-'));
+  const seedFile = path.join(tmp, 'seed.json');
+  const runtimeFile = path.join(tmp, 'runtime.json');
+
+  fs.writeFileSync(seedFile, JSON.stringify({
+    version: 1,
+    schools: [
+      {
+        id: 'sch_seed_1', name: '内置大学', aliases: ['内置'], province: '浙江省', city: '杭州市',
+        lng: 120.1, lat: 30.2, enabled: true, seed: true,
+      },
+      {
+        id: 'sch_seed_2', name: '坐标越界大学', province: '境外',
+        lng: 10, lat: 60, enabled: true, seed: false,
+      },
+    ],
+  }));
+
+  process.env.SCHOOLS_SEED_FILE = seedFile;
+  process.env.SCHOOLS_FILE = runtimeFile;
+  const { loadSchools } = require(path.join(__dirname, '..', 'schools-store.js'));
+
+  await check('读取种子:2 条', () => {
+    const list = loadSchools();
+    expect(list.length === 2, `实际 ${list.length}`);
+    expect(list.every((s) => s.seed === true), '种子项应标记 seed');
+  });
+
+  await check('越界坐标降级为 null(未定位)', () => {
+    const s = loadSchools().find((x) => x.name === '坐标越界大学');
+    expect(s.lng === null && s.lat === null, `lng=${s.lng} lat=${s.lat}`);
+  });
+
+  await check('运行时不存在时只用种子', () => {
+    expect(!fs.existsSync(runtimeFile), '运行时文件不应存在');
+    expect(loadSchools().length === 2, '条数不对');
+  });
+
+  fs.writeFileSync(runtimeFile, JSON.stringify({
+    version: 1,
+    schools: [
+      { name: '内置大学', enabled: false, province: '浙江省' },
+      { id: 'sch_new_1', name: '新学校', province: '江苏省', city: '南京市', lng: 118.8, lat: 32.06, enabled: true },
+    ],
+  }));
+
+  await check('运行时覆盖同名种子的字段', () => {
+    const s = loadSchools().find((x) => x.name === '内置大学');
+    expect(s.enabled === false, 'enabled 未被覆盖');
+    expect(s.lng === 120.1, '未覆盖的字段应保留种子值');
+    expect(s.seed === true, '种子标记不能丢');
+  });
+
+  await check('运行时新增学校并参与排序', () => {
+    const list = loadSchools();
+    expect(list.length === 3, `实际 ${list.length}`);
+    const names = list.map((s) => s.name).join('|');
+    expect(names.includes('新学校'), names);
+  });
+
+  await check('运行时项 seed=false', () => {
+    expect(loadSchools().find((s) => s.name === '新学校').seed === false, 'seed 应为 false');
+  });
+
+  fs.writeFileSync(runtimeFile, '{ broken json');
+  await check('运行时文件损坏:退回种子且不抛错', () => {
+    const list = loadSchools();
+    expect(list.length === 2, `实际 ${list.length}`);
+    expect(list.some((s) => s.name === '内置大学'), '种子缺失');
+  });
+
+  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
+
+  const failed = results.filter((r) => !r.ok);
+  console.log('\n========== 学校数据测试结果 ==========');
+  for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : '  ← ' + r.error}`);
+  console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
+  if (failed.length) process.exit(1);
+}
+
+main().catch((e) => {
+  console.error('测试脚本自身异常:', e);
+  process.exit(1);
+});

+ 1 - 0
server/routes/index.js

@@ -11,6 +11,7 @@ function registerRoutes(app, ctx) {
   require('./history.js').register(app, ctx);
   require('./reports.js').register(app, ctx);
   require('./ops.js').register(app, ctx);
+  require('./schools.js').register(app, ctx);
   require('./agent.js').register(app, ctx);
   require('./static.js').register(app, ctx);
 }

+ 53 - 0
server/routes/schools.js

@@ -0,0 +1,53 @@
+'use strict';
+
+// 校区地图:学校列表(JSON 种子 + 运行时),附带运营数据关联统计。
+// 本期只有只读接口;新建/编辑/启停在下一增量实现。
+
+const { sendJson } = require('../util.js');
+const { loadSchools } = require('../../schools-store.js');
+const { createOpsStore } = require('../../ops-store.js');
+
+function namesOf(school) {
+  return new Set([school.name, ...(school.aliases || [])].map((n) => String(n).trim()).filter(Boolean));
+}
+
+function register(app, ctx) {
+  const opsStore = createOpsStore(ctx.config);
+
+  app.get('/api/schools', async ({ res }) => {
+    const schools = loadSchools();
+
+    // 关联运营数据:按学校名(含别名)匹配 ops_shop.school 与 ops_row.school
+    let shops = [];
+    let rows = [];
+    let opsAvailable = false;
+    try {
+      const summary = await opsStore.summary();
+      shops = summary.shops || [];
+      rows = await opsStore.queryRows({});
+      opsAvailable = true;
+    } catch { /* ops 库不可用:只影响关联字段 */ }
+
+    const payload = schools.map((school) => {
+      const names = namesOf(school);
+      const shopCount = shops.filter((s) => names.has(String(s.school || ''))).length;
+      let lastDate = '';
+      for (const row of rows) {
+        if (!names.has(String(row.school || ''))) continue;
+        if (!lastDate || row.date > lastDate) lastDate = row.date;
+      }
+      return {
+        ...school,
+        ops: opsAvailable ? { shopCount, lastDate: lastDate || null } : null,
+      };
+    });
+
+    sendJson(res, 200, {
+      schools: payload,
+      opsAvailable,
+      enabledCount: payload.filter((s) => s.enabled && s.lng != null).length,
+    });
+  });
+}
+
+module.exports = { register };

+ 4 - 0
server/routes/static.js

@@ -17,6 +17,10 @@ const STATIC_MAP = {
   '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
   '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
   '/ops.js': ['ops.js', 'application/javascript; charset=utf-8'],
+  '/schools.js': ['schools.js', 'application/javascript; charset=utf-8'],
+  '/vendor/china-map.json': ['vendor/china-map.json', 'application/json; charset=utf-8'],
+  '/vendor/cn-universities.json': ['vendor/cn-universities.json', 'application/json; charset=utf-8'],
+  '/vendor/cn-cities.json': ['vendor/cn-cities.json', 'application/json; charset=utf-8'],
   '/ask.js': ['ask.js', 'application/javascript; charset=utf-8'],
   '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],

+ 21 - 0
server/schools.seed.json

@@ -0,0 +1,21 @@
+{
+  "version": 1,
+  "note": "内置学校(随版本发布)。用户新增/修改写入 data/schools.json,读取时按 name 合并,运行时覆盖种子。",
+  "schools": [
+    {
+      "id": "sch_tonglu_001",
+      "name": "浙江工商大学桐庐校区",
+      "aliases": ["浙江工商桐庐校区", "浙江工商大学杭州商学院", "杭州商学院桐庐校区"],
+      "province": "浙江省",
+      "city": "杭州市",
+      "county": "桐庐县",
+      "lng": 119.744166,
+      "lat": 29.830417,
+      "source": "seed",
+      "seed": true,
+      "enabled": true,
+      "created_at": "2026-10-20T00:00:00.000Z",
+      "updated_at": "2026-10-20T00:00:00.000Z"
+    }
+  ]
+}