瀏覽代碼

docs: 校区地图改为「输入名称自动定位」(含 2820 所高校 GCJ-02 坐标库)

liangzhao 2 周之前
父節點
當前提交
caecfadbdb
共有 2 個文件被更改,包括 169 次插入 和 143 次删除
  1. 169 143
      docs/superpowers/specs/2026-10-20-campus-map-design.md
  2. 0 0
      public/vendor/cn-universities.json

+ 169 - 143
docs/superpowers/specs/2026-10-20-campus-map-design.md

@@ -1,153 +1,183 @@
-# 全国校区地图 · 设计文档
+# 全国校区地图 · 设计文档(v2:自动定位版)
 
 - 日期:2026-10-20
 - 分支:`dev`
 - 状态:待评审(本阶段只出设计,不写实现)
+- 变更:**取消**「选省/市 + 地图点选」流程,改为**输入学校名自动定位**
 
 ## 1. 背景与目标
 
-已有「店铺运营」按学校分组看数据,但缺少一个**全国视角**:哪些学校在用、分布在哪、开了几家。
+已有「店铺运营」按学校分组看数据,但缺少全国视角。
 
 新增一级菜单 **校区地图**:
 
 - 中国地图上展示所有学校;**启用的学校点亮**(涟漪 + 名称),停用的显示为灰点
-- 可在界面上**创建学校**:填名称、在地图上点出位置、启用
-- 点击点位或列表项可查看/编辑/停用/删除
+- 界面上**创建学校**:只填名称,系统自动定位并点亮
+- 列表与「店铺运营」数据关联,显示该学校的门店数与最近数据日期
+- 删除有运营数据的学校时二次确认
 
-## 2. 已定的关键决策
+## 2. 关键决策
 
-| 决策 | 结论 | 原因 |
-|---|---|---|
-| 位置来源 | **必须在地图上主动点选**,不提供经纬度输入框 | 一个城市有多所高校,城市中心会重叠且偏差大;手填经纬度对使用者不友好 |
-| 默认位置 | **没有默认位置**,未落点不允许保存 | 避免"看起来创建成功、点却落在市中心" |
-| 城市字段 | 只作为**地图跳转的辅助**(选城市 → 地图飞过去 → 再点精确位置),不决定落点 | 兼顾"快速定位到城市"和"精确到校区" |
-| 省份 | 落点后**自动识别**(点在哪个省),也可手动改 | 避免坐标与省份对不上 |
-| 坐标系 | 统一存 **GCJ-02**(底图坐标系) | 点选天然得到 GCJ-02;混用 WGS84 会有几百米偏差(见 §4.3) |
+| 决策 | 结论 |
+|---|---|
+| 位置来源 | **本地高校坐标库自动匹配**(2820 所),不选省市、不点地图、不填经纬度 |
+| 未收录的学校 | ① 在线地理编码兜底(Photon)→ ② 仍失败则创建为「未定位」,不点亮,列表可点「重新定位」重试 |
+| 坐标系 | 统一 **GCJ-02**(底图坐标系);数据源是 BD-09 需转换,在线兜底是 WGS84 需转换 |
+| 省份 | 由坐标预先算好(点落省份多边形),不需要用户选 |
+| 批量导入 | 不做 |
+| 删除保护 | 有同名运营数据时二次确认 |
 
 ## 3. 交互设计
 
-### 3.1 创建学校(主流程)
+### 3.1 创建学校
 
 ```
-[新建学校] → 弹窗:学校名称
-                   可选:省份 → 城市(仅用于把地图飞过去)
-                   [取消] [在地图上选择位置]
-        ↓
-   弹窗收起,地图进入「定位模式」:
-     顶部提示条:请在地图上点击学校所在位置 · Esc 取消
-     鼠标变十字;可选省份时先飞到该省/市(zoom 6~9)
+[新建学校] → 弹窗:输入「学校名称」(即时联想)
+                 ┌──────────────────────────────┐
+                 │ 中国计量大学     浙江省        │ ← 本地库命中
+                 │ 中国计量大学现代科技学院  浙江省 │
+                 └──────────────────────────────┘
+        ↓ 选中
+   地图自动飞到该点(zoom 8)→ 落标记(脉冲)→ 底部确认卡:
+        ┌──────────────────────────────────────┐
+        │ 中国计量大学 · 浙江省                  │
+        │ 坐标 120.3626, 30.3210(GCJ-02)      │
+        │              [取消]  [创建并点亮]      │
+        └──────────────────────────────────────┘
         ↓
-   点击地图 → 落一个临时标记(脉冲动画)
-     提示条变成:已选位置(自动识别的省份) [重选] [确认创建]
-        ↓
-   确认 → 保存并默认「启用」→ 点位变橙色涟漪,列表新增一条
+   创建成功 → 点位橙色涟漪 + 学校名,列表新增一条
 ```
 
-要点:
+规则:
 
-- **没点位置时"确认创建"不可用**,从流程上杜绝城市中心兜底
-- 落点后自动识别省份;识别不出(点在海上/境外)时提示"该位置不在中国境内,请重新选择"
-- 同城多校因为各自精确点选,天然分散,不会叠在一起
+- **精确且唯一命中**(如「中国计量大学」):直接显示确认卡,一次点击完成
+- **多条命中**(如「中国计量大学」与「中国计量大学现代科技学院」):候选列表选择,带省份区分
+- **无命中**:显示「未收录该学校」+ `[尝试在线定位]`;在线也失败时提供 `[先创建(未定位)]`
+- 创建默认**启用**(点亮),可立即关闭
 
-### 3.2 编辑与重新定位
+### 3.2 编辑
 
-列表项或点位 → 右侧详情卡:
+点列表项 → 右侧详情卡:
 
 ```
-┌ 中国计量大学 ────────────────┐
-│ 浙江省 · 杭州市               │
-│ 坐标 120.3626, 30.3187        │  ← 只读展示,不可编辑输入框
-│ [重新定位]  [停用]  [删除]     │
-└──────────────────────────────┘
+┌ 中国计量大学 ──────────────────┐
+│ 浙江省 · 120.3626, 30.3210      │
+│ 运营数据:9 家门店 · 最近 10-07  │
+│ [重新匹配]  [停用]  [删除]       │
+└────────────────────────────────┘
 ```
 
-- **重新定位**:再次进入定位模式,地图飞到该点(zoom 9),点击新位置后确认覆盖
-- 名称可改(点详情卡标题进入编辑)
-- 删除需二次确认
+- **重新匹配**:回到搜索框,输入新名称重新定位(覆盖坐标)
+- 改名:详情卡标题可编辑
+- 不提供拖动或点选微调(遵循"不点地图"的要求)
+
+### 3.3 删除保护
 
-### 3.3 启用 / 停用
+若「店铺运营」中存在同名学校:
 
-- 列表每项一个开关;打开 = 点亮,关闭 = 灰点
-- 开关即时生效,不需要保存按钮
-- 停用只影响地图展示,不删除学校、不影响「店铺运营」数据
+```
+确定删除「中国计量大学」?
+店铺运营中有 9 家门店使用了该名称,最近数据日期 2026-10-07。
+删除学校不会删除运营数据。
+[取消] [仍然删除]
+```
 
 ### 3.4 地图交互
 
 | 操作 | 行为 |
 |---|---|
-| 滚轮 / 双指 | 缩放(zoom 3 ~ 12) |
+| 滚轮 / 双指 | 缩放(zoom 3~12) |
 | 拖拽 | 平移 |
-| 双击省份 | 放大到该省 |
-| 悬停点位 | tooltip:学校名 · 省/市 · 状态 |
+| 悬停点位 | tooltip:学校名 · 省份 · 门店数 |
 | 点击点位 | 选中并高亮,右侧滚动到对应卡片 |
-| 搜索框 | 按名称过滤列表;点结果 → 地图飞到该点(zoom 9) |
-| [回到全国] | 恢复默认视野(center 104, 35 / zoom 1.2) |
+| 搜索框(列表上方) | 过滤列表;点结果地图飞过去 |
+| [回到全国] | 恢复默认视野 |
 
 ### 3.5 空态与异常
 
 | 场景 | 展示 |
 |---|---|
-| 一所学校都没有 | 地图灰显 + 中央「新建第一所学校」按钮 + 一行说明 |
-| 存储不可用 | 地图正常显示,顶部提示「学校数据暂不可用」+ 重试,创建按钮禁用 |
-| 底图加载失败 | 提示「地图数据加载失败,请刷新」,不渲染空白画布 |
-| 名称重复 | 保存时提示「已存在同名学校」,不创建 |
-| 点位在境外 | 拒绝保存并提示重新选择 |
-
-## 4. 地图技术方案
+| 没有学校 | 地图灰显 + 中央「创建第一所学校」 |
+| 存储不可用 | 地图正常渲染 + 顶部提示「学校数据暂不可用」+ 重试 |
+| 底图加载失败 | 提示刷新,不渲染空画布 |
+| 名称重复 | 保存时提示「已存在同名学校」 |
+| 未定位学校 | 列表中灰色标签「未定位」,可重新定位 |
 
-### 4.1 技术选型
+## 4. 定位数据与算法
 
-- **ECharts 5.6.0**(仓库已内置 `public/vendor/echarts.min.js`),`geo` 组件 + `effectScatter` 系列
-- **底图**:中国省级 GeoJSON,本地内置 `public/vendor/china-map.json`(569KB,35 个省级面,含南海诸岛)
-- **城市定位库**:`public/vendor/cn-cities.json`(19KB,35 省 / 477 个城市区县的中心坐标,只用于"飞到城市")
-- 全部离线,不依赖任何地图服务或 Key
+### 4.1 本地高校库
 
-### 4.2 点亮实现
+`public/vendor/cn-universities.json`(238KB,**2820 所**):
 
-```
-geo(底图,roam 可缩放平移)
-├── effectScatter  series「已启用」
-│     symbolSize 10~14,颜色 #ff6b35
-│     rippleEffect { brushType: 'stroke', scale: 3.2, period: 3.5 }
-│     label { show: true, formatter: 学校名, position: right }
-│
-└── scatter        series「未启用」
-      symbolSize 6,颜色 #b8b2ad,无涟漪、无标签
+```json
+{
+  "中国计量大学": { "lng": 120.362644, "lat": 30.321047, "province": "浙江省" },
+  "浙江科技学院": { "lng": 120.025229, "lat": 30.220034, "province": "浙江省" }
+}
 ```
 
-- 点击:`chart.on('click', params => selectSchool(params.data.id))`
-- 悬停:tooltip formatter 输出三行(名称 / 省市 / 状态)
-- 选中:被选中的点 symbolSize +4、边框加亮,右侧卡片同步高亮
-- 同城多点重叠:先按经纬度绘制;若两点**屏幕像素距离 < 14px**,对后者做 ≤8px 的确定性偏移(按 id 哈希),保证都看得见
+生成方式(构建期一次性;数据已生成,生成脚本在实施阶段一并提供):
 
-### 4.3 坐标系(重要)
+1. 取开源数据集 `flwfdd/china-university-location`(2820 所,百度坐标)
+2. **BD-09 → GCJ-02** 转换
+3. 用省级 GeoJSON 做点在多边形内判断,写入 `province`
+4. 输出精简 JSON(不含冗余字段)
 
-中国互联网地图(阿里 DataV、高德)用的是 **GCJ-02**,而 GPS / OpenStreetMap 是 **WGS84**,两者在国内相差 300~600 米。
+**坐标系验证**(以 Photon 返回的 WGS84 → GCJ-02 为参照):
 
-| 例子 | WGS84 | GCJ-02 | 偏差 |
+| 学校 | 数据集原始(BD-09) | 转换后 GCJ-02 | 与参照偏差 |
 |---|---|---|---|
-| 中国计量大学 | 120.3580, 30.3210 | **120.3626, 30.3187** | 约 440m / 254m |
-| 杭州市中心 | 120.1536, 30.2875 | 120.1583, 30.2851 | 约 452m / 257m |
-| 天安门 | 116.3910, 39.9070 | 116.3972, 39.9084 | 约 533m / 155m |
+| 清华大学 | 116.33337, 40.00964 | 116.32677, 40.00391 | 45m |
+| 北京大学 | 116.31683, 39.99888 | 116.31031, 39.99288 | 33m |
+| 武汉大学 | 114.37292, 30.54380 | 114.36651, 30.53748 | 274m |
+| 中国计量大学 | 120.36904, 30.32740 | 120.36264, 30.32105 | 284m |
+| 复旦大学 | 121.51077, 31.30187 | 121.50419, 31.29623 | 392m |
+
+不转换直接用会是 **850~1200m** 的系统性偏移,所以转换是必须的。残差在校区尺度内(校区本身就有几百米跨度),可接受。
+
+### 4.2 名称匹配
+
+按顺序尝试,命中即止:
 
-结论:
+1. 精确匹配(去首尾空格)
+2. 归一化匹配:去掉括号内容、空格、全半角差异(`中国计量大学(下沙校区)` → `中国计量大学`)
+3. 前缀/包含匹配(`计量大学` → `中国计量大学`)
 
-- **用户在底图上点选的坐标天然是 GCJ-02**,直接存,所见即所得
-- 外部来源(GPS、OSM、别人给的经纬度)是 WGS84,入库前要转换;提供 `wgs84ToGcj02()` 工具函数与一条 `scripts/` 小工具供批量导入
-- 只读展示坐标时标注「GCJ-02」,避免误用
+候选排序:精确 > 归一化 > 包含;同分时名称短的优先。最多返回 8 条,带省份用于区分。
+
+### 4.3 未收录兜底
+
+在线地理编码(服务端发起,避免浏览器跨域):
+
+- 请求 `https://photon.komoot.io/api/?q=<名称>&limit=5`
+- 只接受 `countrycode=CN` 且 `name` 与输入相似度足够高的结果
+- 返回 WGS84 → **WGS84→GCJ-02 转换**后使用
+- 超时 8s;失败则创建为「未定位」
+
+设计上把「在线兜底」做成**可选动作**(用户点按钮触发),不做成创建时自动请求——避免无网络环境下创建流程卡住。
+
+### 4.4 点亮
+
+```
+geo(省级底图,roam)
+├── effectScatter「已启用」:橙色 #ff6b35,rippleEffect,右侧名称标签
+└── scatter「未启用 / 未定位」:灰色 #b8b2ad,无标签
+```
+
+点击、悬停、选中联动;同屏像素距离 <14px 的两点做 ≤8px 确定性偏移。
 
 ## 5. 数据模型
 
-复用现有 ops 存储(默认 SQLite,可切 PG),新增一张表:
+沿用 ops 存储(SQLite 默认 / PG 可切):
 
 ```sql
 CREATE TABLE IF NOT EXISTS ops_school (
-  id         text PRIMARY KEY,          -- 服务端生成,如 sch_20261020_a1b2c3
+  id         text PRIMARY KEY,          -- sch_20261020_a1b2c3
   name       text NOT NULL,
   province   text NOT NULL DEFAULT '',
-  city       text NOT NULL DEFAULT '',
-  lng        real NOT NULL,             -- GCJ-02
-  lat        real NOT NULL,
+  lng        real,                      -- GCJ-02;未定位时为 NULL
+  lat        real,
+  source     text NOT NULL DEFAULT 'local',  -- local | geocode | none
   enabled    integer NOT NULL DEFAULT 1,
   created_at text NOT NULL,
   updated_at text NOT NULL
@@ -155,95 +185,91 @@ CREATE TABLE IF NOT EXISTS ops_school (
 CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
 ```
 
-设计说明:
-
-- `name` 唯一,防止重复创建同一所学校
-- `province` / `city` 为展示与分组用;`province` 由落点自动识别,`city` 取用户在下拉里选的值(允许为空)
-- 与「店铺运营」的关联:`ops_shop.school` 是字符串,按 **名称相等** 关联;创建时会提示「与 N 个运营数据中的学校匹配」,对不上也允许创建(先画图、后补数据)
-- `enabled` 用 0/1,SQLite 与 PG 都兼容
+- `lng/lat` 允许 NULL = 未定位(不点亮)
+- `source` 记录定位来源,便于排查
+- 与运营数据的关联:按 `ops_shop.school = ops_school.name` 精确匹配,查询时聚合门店数与 `MAX(date)`
 
 ## 6. 接口
 
 | 方法 | 路径 | 说明 |
 |---|---|---|
-| GET | `/api/schools` | 列表(含 enabled、坐标、省市) |
-| POST | `/api/schools` | 创建;`{ name, province, city, lng, lat, enabled? }` |
-| POST | `/api/schools/:id` | 更新(改名、重新定位) |
-| POST | `/api/schools/:id/toggle` | 启用/停用 `{ enabled }` |
+| GET | `/api/schools` | 列表,含 enabled、坐标、门店数、最近数据日期 |
+| GET | `/api/schools/search?q=` | 本地高校库联想(也可前端直接加载 JSON 搜索,二选一) |
+| POST | `/api/schools` | 创建:`{ name, lng?, lat?, province?, source? }`;缺坐标时按名称自动匹配 |
+| POST | `/api/schools/:id` | 更新(改名 / 重新匹配 / 坐标) |
+| POST | `/api/schools/:id/toggle` | 启用/停用 |
+| POST | `/api/schools/locate` | 在线兜底:`{ name }` → `{ lng, lat, province, source }` |
 | DELETE | `/api/schools/:id` | 删除 |
 
-校验:
-
-- `name`:去空白、1~60 字符、唯一
-- `lng` / `lat`:必填;中国范围(lng 73~136、lat 3~54)之外拒绝
-- `province` / `city`:可选,≤40 字符
-- 存储不可用:`GET` 返回 200 + `{ enabled:false, reason }`,写操作 503(与 ops 现有一致)
+校验:名称 1~60 且唯一;坐标若提供必须在 GCJ-02 中国范围(lng 73~136,lat 3~54)。
 
-## 7. 界面布局
+## 7. 界面
 
 ```
 ┌──────────────────────────────────────────────────────────────┐
-│ 校区地图                         + 新建学校                  │
+│ 校区地图                          + 新建学校                 │
 │ 已启用 3 / 共 5                                              │
 ├───────────────────────────────────┬──────────────────────────┤
-│                                   │ 🔍 搜索学校               │
-│         中国地图(可缩放平移)      │ ┌──────────────────────┐ │
-│                                   │ │● 中国计量大学 [开]    │ │
-│        ● 中国计量大学              │ │  浙江 · 杭州          │ │
-│        ● 浙江工商大学桐庐校区       │ ├──────────────────────┤ │
-│        ○ 浙江科技学院(未启用)     │ │○ 浙江科技学院 [关]    │ │
-│                                   │ │  浙江 · 杭州          │ │
-│                  [回到全国]        │ └──────────────────────┘ │
+│                                   │ 🔍 搜索                   │
+│        中国地图(可缩放)           │ 浙江省 2/3               │
+│                                   │ ┌──────────────────────┐ │
+│      ● 中国计量大学                │ │● 中国计量大学 [开]    │ │
+│      ● 浙江工商大学桐庐校区         │ │  9 店 · 最近 10-07    │ │
+│      ○ 浙江科技学院(未启用)       │ ├──────────────────────┤ │
+│                                   │ │○ 浙江科技学院 [关]    │ │
+│                  [回到全国]        │ │  未关联运营数据        │ │
+│                                   │ └──────────────────────┘ │
 └───────────────────────────────────┴──────────────────────────┘
 ```
 
-- 左侧地图占 2/3 以上,右侧列表 320px
-- 列表按省份分组,组头显示该省启用数(如「浙江省 2/3」)
-- 窄屏(<1100px)时列表折叠到地图下方
+- 列表按省份分组,组头显示启用数
+- 每项显示「N 店 · 最近日期」;无关联数据显示「未关联运营数据」
+- 点击列表项 → 地图飞过去并选中
+- 宽度 <1100px 时列表移到地图下方
 
 ## 8. 与现有功能的关系
 
-- **独立菜单**,不改「店铺运营」的现有行为
-- 数据上通过 `ops_shop.school` 名称关联,为下一步「点击地图上的学校 → 打开店铺运营并筛选该学校」留好接口(本期不做跳转)
-- 不参与采集、不参与钉钉推送、不影响任何现有接口
+- 独立菜单,不改动店铺运营现有行为
+- 通过 `ops_shop.school` 名称关联,仅做**只读**展示(门店数、最近日期)
+- 不参与采集、不参与钉钉推送
 
 ## 9. 测试
 
 ### 9.1 单元
 
-- 坐标范围校验(境内/境外)
-- 名称去重与去空白
-- 省份自动识别(点在浙江/北京/境外三种情况)
-- WGS84 → GCJ-02 转换(用 §4.3 三组已知值做断言,误差 < 1e-4)
+- BD-09 → GCJ-02 转换(用 §4.1 五组值断言,误差 < 200m)
+- 名称匹配:精确 / 带括号 / 简称 / 无命中
+- 坐标范围校验(境内 / 境外)
+- 省份识别(中国计量大学 → 浙江省;境外点 → 空)
 
 ### 9.2 接口(临时 SQLite)
 
-- 创建 → 列表可见;重复名称 400
-- 切换启用状态;删除后列表消失
-- 坐标缺省 / 越界 → 400
-- 存储不可用 → GET 200 + `enabled:false`,POST 503
+- 创建(带坐标 / 仅名称自动匹配)→ 列表可见
+- 重名 400;停用/启用;删除
+- 未定位学校(lng/lat 为空)不报错,`enabled=1` 也不点亮
+- 关联字段:造 2 条 `ops_shop` 同名数据 → 返回门店数 2 与最近日期
+- 存储不可用 → GET 200 + `enabled:false`,写操作 503
 
 ### 9.3 冒烟
 
-`GET /api/schools` 200(只读,不新增写操作)
+`GET /api/schools` 200(只读)
 
 ### 9.4 手工验收
 
-新建「中国计量大学」→ 选浙江省 → 地图飞到浙江 → 点下沙位置 → 保存 → 点位点亮并显示名称 → 刷新页面仍在 → 停用变灰 → 重新启用再点亮 → 删除后消失。
+输入「中国计量大学」→ 联想出现 → 选中 → 地图飞到杭州下沙 → 创建 → 点位点亮 → 刷新仍在 → 列表显示关联门店数与最近日期 → 停用变灰 → 删除时若有运营数据出现二次确认。
 
-## 10. 待办清单(通过评审后拆实施计划)
+## 10. 待办清单(评审通过后拆计划)
 
-- [ ] `public/vendor/china-map.json`、`cn-cities.json`(已就绪,需登记到静态白名单)
-- [ ] `ops_school` 表与存储方法(SQLite + PG 两驱动)
-- [ ] `/api/schools` 五个端点 + 校验 + 存储降级
-- [ ] `public/schools.js`:地图渲染、点亮、定位模式、列表联动、创建/编辑弹窗
-- [ ] `index.html` 菜单与页面骨架、`app.js` 视图注册、`style.css`
-- [ ] 坐标工具:`wgs84ToGcj02()` + 省份识别(点在多边形内)
-- [ ] 测试脚本扩展(单元 + 接口)与 smoke 只读断言
-- [ ] README 增补「校区地图」章节与坐标系说明
+- [ ] `scripts/build-university-data.js`(数据生成脚本,含 BD-09 转换与省份归属;数据文件已就绪)
+- [ ] `public/vendor/cn-universities.json`(已生成,待登记静态白名单)
+- [ ] `ops_school` 表 + 存储方法(SQLite / PG)
+- [ ] `/api/schools` 端点(含 search / locate / 关联查询 / 删除保护所需计数)
+- [ ] `public/schools.js`:地图、联想、创建、编辑、启停、删除确认、列表联动
+- [ ] `index.html` 菜单与页面、`app.js` 注册、`style.css`
+- [ ] 测试脚本扩展 + smoke 只读断言
+- [ ] README 增补「校区地图」与坐标系说明
 
-## 11. 开放问题(实施前需确认)
+## 11. 待确认
 
-1. 学校列表是否要显示「该学校在店铺运营里有几家店 / 最近有数据的日期」?(需要与 ops 数据联查,工作量 +0.5 天)
-2. 删除学校时,如果「店铺运营」里已有同名学校的数据,是否需要二次确认并提示影响?
-3. 是否需要「批量导入学校」(给一份 Excel/CSV,含名称+坐标)?本期默认不做。
+1. 「未收录学校」走在线兜底失败后,是否允许创建为「未定位」(灰点、不点亮)?还是直接拒绝创建?
+2. 列表点击是否要顺带跳转到「店铺运营并筛选该学校」?(关联体验更完整,约 +0.5 天)

文件差異過大導致無法顯示
+ 0 - 0
public/vendor/cn-universities.json


部分文件因文件數量過多而無法顯示