# 全国校区地图 · 设计文档 - 日期:2026-10-20 - 分支:`dev` - 状态:待评审(本阶段只出设计,不写实现) ## 1. 背景与目标 已有「店铺运营」按学校分组看数据,但缺少一个**全国视角**:哪些学校在用、分布在哪、开了几家。 新增一级菜单 **校区地图**: - 中国地图上展示所有学校;**启用的学校点亮**(涟漪 + 名称),停用的显示为灰点 - 可在界面上**创建学校**:填名称、在地图上点出位置、启用 - 点击点位或列表项可查看/编辑/停用/删除 ## 2. 已定的关键决策 | 决策 | 结论 | 原因 | |---|---|---| | 位置来源 | **必须在地图上主动点选**,不提供经纬度输入框 | 一个城市有多所高校,城市中心会重叠且偏差大;手填经纬度对使用者不友好 | | 默认位置 | **没有默认位置**,未落点不允许保存 | 避免"看起来创建成功、点却落在市中心" | | 城市字段 | 只作为**地图跳转的辅助**(选城市 → 地图飞过去 → 再点精确位置),不决定落点 | 兼顾"快速定位到城市"和"精确到校区" | | 省份 | 落点后**自动识别**(点在哪个省),也可手动改 | 避免坐标与省份对不上 | | 坐标系 | 统一存 **GCJ-02**(底图坐标系) | 点选天然得到 GCJ-02;混用 WGS84 会有几百米偏差(见 §4.3) | ## 3. 交互设计 ### 3.1 创建学校(主流程) ``` [新建学校] → 弹窗:学校名称 可选:省份 → 城市(仅用于把地图飞过去) [取消] [在地图上选择位置] ↓ 弹窗收起,地图进入「定位模式」: 顶部提示条:请在地图上点击学校所在位置 · Esc 取消 鼠标变十字;可选省份时先飞到该省/市(zoom 6~9) ↓ 点击地图 → 落一个临时标记(脉冲动画) 提示条变成:已选位置(自动识别的省份) [重选] [确认创建] ↓ 确认 → 保存并默认「启用」→ 点位变橙色涟漪,列表新增一条 ``` 要点: - **没点位置时"确认创建"不可用**,从流程上杜绝城市中心兜底 - 落点后自动识别省份;识别不出(点在海上/境外)时提示"该位置不在中国境内,请重新选择" - 同城多校因为各自精确点选,天然分散,不会叠在一起 ### 3.2 编辑与重新定位 列表项或点位 → 右侧详情卡: ``` ┌ 中国计量大学 ────────────────┐ │ 浙江省 · 杭州市 │ │ 坐标 120.3626, 30.3187 │ ← 只读展示,不可编辑输入框 │ [重新定位] [停用] [删除] │ └──────────────────────────────┘ ``` - **重新定位**:再次进入定位模式,地图飞到该点(zoom 9),点击新位置后确认覆盖 - 名称可改(点详情卡标题进入编辑) - 删除需二次确认 ### 3.3 启用 / 停用 - 列表每项一个开关;打开 = 点亮,关闭 = 灰点 - 开关即时生效,不需要保存按钮 - 停用只影响地图展示,不删除学校、不影响「店铺运营」数据 ### 3.4 地图交互 | 操作 | 行为 | |---|---| | 滚轮 / 双指 | 缩放(zoom 3 ~ 12) | | 拖拽 | 平移 | | 双击省份 | 放大到该省 | | 悬停点位 | tooltip:学校名 · 省/市 · 状态 | | 点击点位 | 选中并高亮,右侧滚动到对应卡片 | | 搜索框 | 按名称过滤列表;点结果 → 地图飞到该点(zoom 9) | | [回到全国] | 恢复默认视野(center 104, 35 / zoom 1.2) | ### 3.5 空态与异常 | 场景 | 展示 | |---|---| | 一所学校都没有 | 地图灰显 + 中央「新建第一所学校」按钮 + 一行说明 | | 存储不可用 | 地图正常显示,顶部提示「学校数据暂不可用」+ 重试,创建按钮禁用 | | 底图加载失败 | 提示「地图数据加载失败,请刷新」,不渲染空白画布 | | 名称重复 | 保存时提示「已存在同名学校」,不创建 | | 点位在境外 | 拒绝保存并提示重新选择 | ## 4. 地图技术方案 ### 4.1 技术选型 - **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.2 点亮实现 ``` 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,无涟漪、无标签 ``` - 点击:`chart.on('click', params => selectSchool(params.data.id))` - 悬停:tooltip formatter 输出三行(名称 / 省市 / 状态) - 选中:被选中的点 symbolSize +4、边框加亮,右侧卡片同步高亮 - 同城多点重叠:先按经纬度绘制;若两点**屏幕像素距离 < 14px**,对后者做 ≤8px 的确定性偏移(按 id 哈希),保证都看得见 ### 4.3 坐标系(重要) 中国互联网地图(阿里 DataV、高德)用的是 **GCJ-02**,而 GPS / OpenStreetMap 是 **WGS84**,两者在国内相差 300~600 米。 | 例子 | WGS84 | 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 | 结论: - **用户在底图上点选的坐标天然是 GCJ-02**,直接存,所见即所得 - 外部来源(GPS、OSM、别人给的经纬度)是 WGS84,入库前要转换;提供 `wgs84ToGcj02()` 工具函数与一条 `scripts/` 小工具供批量导入 - 只读展示坐标时标注「GCJ-02」,避免误用 ## 5. 数据模型 复用现有 ops 存储(默认 SQLite,可切 PG),新增一张表: ```sql CREATE TABLE IF NOT EXISTS ops_school ( 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, enabled integer NOT NULL DEFAULT 1, created_at text NOT NULL, updated_at text NOT NULL ); 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 都兼容 ## 6. 接口 | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/schools` | 列表(含 enabled、坐标、省市) | | POST | `/api/schools` | 创建;`{ name, province, city, lng, lat, enabled? }` | | POST | `/api/schools/:id` | 更新(改名、重新定位) | | POST | `/api/schools/:id/toggle` | 启用/停用 `{ enabled }` | | 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 现有一致) ## 7. 界面布局 ``` ┌──────────────────────────────────────────────────────────────┐ │ 校区地图 + 新建学校 │ │ 已启用 3 / 共 5 │ ├───────────────────────────────────┬──────────────────────────┤ │ │ 🔍 搜索学校 │ │ 中国地图(可缩放平移) │ ┌──────────────────────┐ │ │ │ │● 中国计量大学 [开] │ │ │ ● 中国计量大学 │ │ 浙江 · 杭州 │ │ │ ● 浙江工商大学桐庐校区 │ ├──────────────────────┤ │ │ ○ 浙江科技学院(未启用) │ │○ 浙江科技学院 [关] │ │ │ │ │ 浙江 · 杭州 │ │ │ [回到全国] │ └──────────────────────┘ │ └───────────────────────────────────┴──────────────────────────┘ ``` - 左侧地图占 2/3 以上,右侧列表 320px - 列表按省份分组,组头显示该省启用数(如「浙江省 2/3」) - 窄屏(<1100px)时列表折叠到地图下方 ## 8. 与现有功能的关系 - **独立菜单**,不改「店铺运营」的现有行为 - 数据上通过 `ops_shop.school` 名称关联,为下一步「点击地图上的学校 → 打开店铺运营并筛选该学校」留好接口(本期不做跳转) - 不参与采集、不参与钉钉推送、不影响任何现有接口 ## 9. 测试 ### 9.1 单元 - 坐标范围校验(境内/境外) - 名称去重与去空白 - 省份自动识别(点在浙江/北京/境外三种情况) - WGS84 → GCJ-02 转换(用 §4.3 三组已知值做断言,误差 < 1e-4) ### 9.2 接口(临时 SQLite) - 创建 → 列表可见;重复名称 400 - 切换启用状态;删除后列表消失 - 坐标缺省 / 越界 → 400 - 存储不可用 → GET 200 + `enabled:false`,POST 503 ### 9.3 冒烟 `GET /api/schools` 200(只读,不新增写操作) ### 9.4 手工验收 新建「中国计量大学」→ 选浙江省 → 地图飞到浙江 → 点下沙位置 → 保存 → 点位点亮并显示名称 → 刷新页面仍在 → 停用变灰 → 重新启用再点亮 → 删除后消失。 ## 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 增补「校区地图」章节与坐标系说明 ## 11. 开放问题(实施前需确认) 1. 学校列表是否要显示「该学校在店铺运营里有几家店 / 最近有数据的日期」?(需要与 ops 数据联查,工作量 +0.5 天) 2. 删除学校时,如果「店铺运营」里已有同名学校的数据,是否需要二次确认并提示影响? 3. 是否需要「批量导入学校」(给一份 Excel/CSV,含名称+坐标)?本期默认不做。