# 全国校区地图 · 设计文档(v2:自动定位版) - 日期:2026-10-20 - 分支:`dev` - 状态:待评审(本阶段只出设计,不写实现) - 变更:**取消**「选省/市 + 地图点选」流程,改为**输入学校名自动定位** - v3 变更:学校数据改为 **JSON 静态存储**(种子文件 + 运行时文件),不再用数据库;内置「浙江工商桐庐校区」 ## 1. 背景与目标 已有「店铺运营」按学校分组看数据,但缺少全国视角。 新增一级菜单 **校区地图**: - 中国地图上展示所有学校;**启用的学校点亮**(涟漪 + 名称),停用的显示为灰点 - 界面上**创建学校**:只填名称,系统自动定位并点亮 - 列表与「店铺运营」数据关联,显示该学校的门店数与最近数据日期 - 删除有运营数据的学校时二次确认 ## 2. 关键决策 | 决策 | 结论 | |---|---| | 位置来源 | **本地高校坐标库自动匹配**(2820 所),不选省市、不点地图、不填经纬度 | | 未收录的学校 | ① 在线地理编码兜底(Photon)→ ② 仍失败则创建为「未定位」,不点亮,列表可点「重新定位」重试 | | 坐标系 | 统一 **GCJ-02**(底图坐标系);数据源是 BD-09 需转换,在线兜底是 WGS84 需转换 | | 省份 | 由坐标预先算好(点落省份多边形),不需要用户选 | | 存储 | **JSON 文件**(种子 + 运行时),不依赖数据库;Node 18 也可用 | | 内置学校 | 预置「浙江工商大学桐庐校区」(别名:浙江工商桐庐校区等) | | 批量导入 | 不做 | | 删除保护 | 有同名运营数据时二次确认 | ## 3. 交互设计 ### 3.1 创建学校 ``` [新建学校] → 弹窗:输入「学校名称」(即时联想) ┌──────────────────────────────┐ │ 中国计量大学 浙江省 │ ← 本地库命中 │ 中国计量大学现代科技学院 浙江省 │ └──────────────────────────────┘ ↓ 选中 地图自动飞到该点(zoom 8)→ 落标记(脉冲)→ 底部确认卡: ┌──────────────────────────────────────┐ │ 中国计量大学 · 浙江省 │ │ 坐标 120.3626, 30.3210(GCJ-02) │ │ [取消] [创建并点亮] │ └──────────────────────────────────────┘ ↓ 创建成功 → 点位橙色涟漪 + 学校名,列表新增一条 ``` 规则: - **精确且唯一命中**(如「中国计量大学」):直接显示确认卡,一次点击完成 - **多条命中**(如「中国计量大学」与「中国计量大学现代科技学院」):候选列表选择,带省份区分 - **无命中**:显示「未收录该学校」+ `[尝试在线定位]`;在线也失败时提供 `[先创建(未定位)]` - 创建默认**启用**(点亮),可立即关闭 ### 3.2 编辑 点列表项 → 右侧详情卡: ``` ┌ 中国计量大学 ──────────────────┐ │ 浙江省 · 120.3626, 30.3210 │ │ 运营数据:9 家门店 · 最近 10-07 │ │ [重新匹配] [停用] [删除] │ └────────────────────────────────┘ ``` - **重新匹配**:回到搜索框,输入新名称重新定位(覆盖坐标) - 改名:详情卡标题可编辑 - 不提供拖动或点选微调(遵循"不点地图"的要求) ### 3.3 删除保护 若「店铺运营」中存在同名学校: ``` 确定删除「中国计量大学」? 店铺运营中有 9 家门店使用了该名称,最近数据日期 2026-10-07。 删除学校不会删除运营数据。 [取消] [仍然删除] ``` ### 3.4 地图交互 | 操作 | 行为 | |---|---| | 滚轮 / 双指 | 缩放(zoom 3~12) | | 拖拽 | 平移 | | 悬停点位 | tooltip:学校名 · 省份 · 门店数 | | 点击点位 | 选中并高亮,右侧滚动到对应卡片 | | 搜索框(列表上方) | 过滤列表;点结果地图飞过去 | | [回到全国] | 恢复默认视野 | ### 3.5 空态与异常 | 场景 | 展示 | |---|---| | 没有学校 | 地图灰显 + 中央「创建第一所学校」 | | 存储不可用 | 地图正常渲染 + 顶部提示「学校数据暂不可用」+ 重试 | | 底图加载失败 | 提示刷新,不渲染空画布 | | 名称重复 | 保存时提示「已存在同名学校」 | | 未定位学校 | 列表中灰色标签「未定位」,可重新定位 | ## 4. 定位数据与算法 ### 4.1 本地高校库 `public/vendor/cn-universities.json`(238KB,**2820 所**): ```json { "中国计量大学": { "lng": 120.362644, "lat": 30.321047, "province": "浙江省" }, "浙江科技学院": { "lng": 120.025229, "lat": 30.220034, "province": "浙江省" } } ``` 生成方式(构建期一次性;数据已生成,生成脚本在实施阶段一并提供): 1. 取开源数据集 `flwfdd/china-university-location`(2820 所,百度坐标) 2. **BD-09 → GCJ-02** 转换 3. 用省级 GeoJSON 做点在多边形内判断,写入 `province` 4. 输出精简 JSON(不含冗余字段) **坐标系验证**(以 Photon 返回的 WGS84 → GCJ-02 为参照): | 学校 | 数据集原始(BD-09) | 转换后 GCJ-02 | 与参照偏差 | |---|---|---|---| | 清华大学 | 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. 前缀/包含匹配(`计量大学` → `中国计量大学`) 候选排序:精确 > 归一化 > 包含;同分时名称短的优先。最多返回 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. 数据模型(JSON 静态存储) ### 5.1 两类文件 | 文件 | 是否提交到仓库 | 作用 | |---|---|---| | `server/schools.seed.json` | **提交** | 内置学校(随版本发布);初始只有浙江工商桐庐校区 | | `data/schools.json` | 不提交(`data/` 已 gitignore) | 运行时新增/修改的学校,以及对内置项的覆盖 | 读取时**合并**两者,对外的列表就是合并结果;写入只动 `data/schools.json`。 ### 5.2 条目结构 ```json { "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" } ``` 字段说明: - `name` 为唯一键;`aliases` 只用于**搜索联想**,不参与唯一性 - `lng/lat`(GCJ-02)为 `null` 表示「未定位」(灰点、不点亮) - `seed: true` 表示内置项 - 内置项坐标:浙江工商大学杭州商学院桐庐校区(桐庐县),`[119.744166, 29.830417]` ### 5.3 合并与写回规则 | 操作 | 行为 | |---|---| | 读取 | 种子 + 运行时;同名(`name`)时运行时字段覆盖种子 | | 新建 | 只写 `data/schools.json`;若与种子/运行时同名 → 400 | | 修改(改名/重新定位/启停) | 写 `data/schools.json` 的同名记录(对种子项即"覆盖层") | | 删除 | 运行时项:从文件移除;**种子项:拒绝删除**,提示「内置学校只能停用」 | | 文件缺失/损坏 | 用种子继续工作,日志 warning,不崩溃 | | 写入 | 临时文件 + rename 原子替换;单进程串行 | ### 5.4 与运营数据的关联 学校存 JSON,运营数据在 ops 库(SQLite/PG),关联在读取时做: - 按 `ops_school_name`(即学校名及别名)去 `ops_shop.school` 精确匹配 - 聚合出「门店数」与「最近数据日期」 - **ops 库不可用时**:地图与学校管理照常,关联字段显示「未关联运营数据」 ### 5.5 内置学校 | 字段 | 值 | |---|---| | 名称 | 浙江工商大学桐庐校区 | | 别名 | 浙江工商桐庐校区 / 浙江工商大学杭州商学院 / 杭州商学院桐庐校区 | | 省/市/县 | 浙江省 / 杭州市 / 桐庐县 | | 坐标(GCJ-02) | 119.744166, 29.830417 | | 默认状态 | 启用(点亮) | > 名称采用「浙江工商大学桐庐校区」是为了和上传表里的 `学校名称` 对得上(人工统计表用的就是全称),别名保证用户输入简称也能搜到。 ## 6. 接口 | 方法 | 路径 | 说明 | |---|---|---| | 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` | 删除 | 校验:名称 1~60 且唯一;坐标若提供必须在 GCJ-02 中国范围(lng 73~136,lat 3~54)。 存储相关:写操作失败(磁盘只读等)返回 500 并保持文件原样;`GET /api/schools` 始终可用(JSON 不依赖数据库)。测试通过环境变量 `SCHOOLS_FILE` 指定临时文件,避免污染本机 `data/schools.json`。 ## 7. 界面 ``` ┌──────────────────────────────────────────────────────────────┐ │ 校区地图 + 新建学校 │ │ 已启用 3 / 共 5 │ ├───────────────────────────────────┬──────────────────────────┤ │ │ 🔍 搜索 │ │ 中国地图(可缩放) │ 浙江省 2/3 │ │ │ ┌──────────────────────┐ │ │ ● 中国计量大学 │ │● 中国计量大学 [开] │ │ │ ● 浙江工商大学桐庐校区 │ │ 9 店 · 最近 10-07 │ │ │ ○ 浙江科技学院(未启用) │ ├──────────────────────┤ │ │ │ │○ 浙江科技学院 [关] │ │ │ [回到全国] │ │ 未关联运营数据 │ │ │ │ └──────────────────────┘ │ └───────────────────────────────────┴──────────────────────────┘ ``` - 列表按省份分组,组头显示启用数 - 每项显示「N 店 · 最近日期」;无关联数据显示「未关联运营数据」 - 点击列表项 → 地图飞过去并选中 - 详情卡提供「查看运营数据」→ 跳转「店铺运营」并预选该校(按名称匹配) - 宽度 <1100px 时列表移到地图下方 ## 8. 与现有功能的关系 - 独立菜单,不改动店铺运营现有行为 - 通过 `ops_shop.school` 名称关联,仅做**只读**展示(门店数、最近日期) - 不参与采集、不参与钉钉推送 ## 9. 测试 ### 9.1 单元 - BD-09 → GCJ-02 转换(用 §4.1 五组值断言,误差 < 200m) - 名称匹配:精确 / 带括号 / 简称 / 无命中 - 坐标范围校验(境内 / 境外) - 省份识别(中国计量大学 → 浙江省;境外点 → 空) ### 9.2 接口(临时 SQLite) - 创建(带坐标 / 仅名称自动匹配)→ 列表可见 - 重名 400;停用/启用;删除 - 未定位学校(lng/lat 为空)不报错,`enabled=1` 也不点亮 - 关联字段:造 2 条 `ops_shop` 同名数据 → 返回门店数 2 与最近日期 - 内置项不可删除(400),可停用;运行时项可删除 - 种子 + 运行时合并:同名时运行时覆盖 - 文件损坏:仍能返回种子数据,不 500 - ops 库不可用:`GET /api/schools` 仍 200,关联字段为空 ### 9.3 冒烟 `GET /api/schools` 200(只读) ### 9.4 手工验收 输入「中国计量大学」→ 联想出现 → 选中 → 地图飞到杭州下沙 → 创建 → 点位点亮 → 刷新仍在 → 列表显示关联门店数与最近日期 → 停用变灰 → 删除时若有运营数据出现二次确认。 ## 10. 待办清单(评审通过后拆计划) - [ ] `scripts/build-university-data.js`(数据生成脚本,含 BD-09 转换与省份归属;数据文件已就绪) - [ ] `public/vendor/cn-universities.json`(已生成,待登记静态白名单) - [ ] `server/schools.seed.json`(内置浙江工商桐庐校区) - [ ] `schools-store.js`:种子 + 运行时文件合并、原子写、损坏兜底 - [ ] `/api/schools` 端点(含 search / locate / 关联查询 / 删除保护) - [ ] `public/schools.js`:地图、联想、创建、编辑、启停、删除确认、列表联动 - [ ] `index.html` 菜单与页面、`app.js` 注册、`style.css` - [ ] 测试脚本扩展 + smoke 只读断言 - [ ] README 增补「校区地图」与坐标系说明 ## 11. 已确认事项 1. **未收录学校**:在线地理编码兜底 → 失败允许创建为「未定位」(灰点、不点亮),列表内可重试定位 2. **关联跳转**:详情卡提供「查看运营数据」,跳转「店铺运营」并预选该学校 3. 不做批量导入;创建后默认启用 4. 学校数据用 JSON(种子 + 运行时),不进数据库;内置项只能停用不能删除