فهرست منبع

feat: 导入数据里的新校区自动出现在校区地图

- /api/schools 合并运营数据中「注册表没有」的学校;按本地高校坐标库自动定位
- 只在去掉「校区/分校」后能匹配到高校时按本校坐标估算,并标记 approx(大致位置)
- 匹配不到坐标则列为「未定位」,不计入点亮数
- 列表增加「导入」「大致位置」徽标,tooltip 标注来源与精度
- 学校标签重叠时自动隐藏(悬停仍可看),避免多点挤在一起
liangzhao 2 هفته پیش
والد
کامیت
c7fcfcac1f
2فایلهای تغییر یافته به همراه71 افزوده شده و 2 حذف شده
  1. 6 2
      public/schools.js
  2. 65 0
      server/routes/schools.js

+ 6 - 2
public/schools.js

@@ -115,7 +115,9 @@
           }
           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>`;
+          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: {
@@ -170,6 +172,7 @@
             fontSize: 11,
             color: '#5b534c',
           },
+          labelLayout: { hideOverlap: true },
           emphasis: { scale: 1.4 },
           zlevel: 2,
         },
@@ -257,7 +260,8 @@
             <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.lng == null ? '<span class="badge badge-warn">未定位</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>

+ 65 - 0
server/routes/schools.js

@@ -3,10 +3,45 @@
 // 校区地图:学校列表(JSON 种子 + 运行时),附带运营数据关联统计。
 // 本期只有只读接口;新建/编辑/启停在下一增量实现。
 
+const fs = require('fs');
+const path = require('path');
 const { sendJson } = require('../util.js');
 const { loadSchools } = require('../../schools-store.js');
 const { createOpsStore } = require('../../ops-store.js');
 
+// 导入数据里出现、但还没登记的校区:用本地高校坐标库自动定位(找不到就标未定位)
+let uniCache = null;
+function loadUniversities() {
+  if (uniCache) return uniCache;
+  try {
+    uniCache = JSON.parse(fs.readFileSync(path.join(__dirname, '../../public/vendor/cn-universities.json'), 'utf8'));
+  } catch {
+    uniCache = {};
+  }
+  return uniCache;
+}
+
+function normalizeSchoolName(name) {
+  return String(name || '')
+    .replace(/\s+/g, '')
+    .replace(/[((].*?[))]/g, '')
+    .replace(/(校区|分校)$/, '');
+}
+
+function matchUniversity(name) {
+  const uni = loadUniversities();
+  const norm = normalizeSchoolName(name);
+  if (!norm) return null;
+  if (uni[norm]) return { lng: uni[norm].lng, lat: uni[norm].lat, province: uni[norm].province, approx: false };
+  let bestKey = '';
+  for (const key of Object.keys(uni)) {
+    if (key.length < 4) continue;
+    if (norm.startsWith(key) && key.length > bestKey.length) bestKey = key;
+  }
+  if (!bestKey) return null;
+  return { lng: uni[bestKey].lng, lat: uni[bestKey].lat, province: uni[bestKey].province, approx: true, base: bestKey };
+}
+
 function namesOf(school) {
   return new Set([school.name, ...(school.aliases || [])].map((n) => String(n).trim()).filter(Boolean));
 }
@@ -30,6 +65,36 @@ function register(app, ctx) {
       opsAvailable = true;
     } catch { /* ops 库不可用:只影响关联字段 */ }
 
+    // 运营数据里出现、注册表里没有的学校:自动补进地图列表
+    const knownNames = new Set();
+    for (const school of schools) for (const n of namesOf(school)) knownNames.add(n);
+    const discovered = new Set();
+    for (const item of [...shops, ...platformRows]) {
+      const name = String(item.school || '').trim();
+      if (name && !knownNames.has(name)) discovered.add(name);
+    }
+    const now = new Date().toISOString();
+    for (const name of discovered) {
+      const hit = matchUniversity(name);
+      schools.push({
+        id: 'ops_' + Buffer.from(name).toString('hex').slice(0, 12),
+        name,
+        aliases: [],
+        province: (hit && hit.province) || '',
+        city: '',
+        county: '',
+        lng: hit ? hit.lng : null,
+        lat: hit ? hit.lat : null,
+        source: 'ops',
+        seed: false,
+        discovered: true,
+        approx: !!(hit && hit.approx),
+        enabled: !!hit,
+        created_at: now,
+        updated_at: now,
+      });
+    }
+
     const payload = schools.map((school) => {
       const names = namesOf(school);
       // 门店口径:人工表的 shop_key 与平台明细的 shopId 取并集