Files
FamilyBook/app/Geo.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

335 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 城市 → 经纬度(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
*
* 所以天气的地点从「管理员在系统设置里填经纬度」改成了「**每个人**在自己的
* 个人设置里填城市名」。于是就必须解决"城市名 → 坐标"这一步。
*
* 三级回落,**每一级都可用**:
* ① 内置表(下面 CITIES):全国地级市 + 直辖市 + 计划单列市,共 197 条。
* **不联网**、不受外部接口抖动影响 —— 绝大多数人到这里就结束了。
* ② 个人设置留了「自定义经纬度」两个框:内置表里没有的地方(镇、村、海外)
* 直接填经纬度即可,不用等我们加表。
* ③ 都没有 → 首页整块不显示天气。
*
* ⚠️ 为什么不直接让用户填经纬度就完了:普通人不知道自己在哪个坐标。
* 填城市名是"人话",坐标是我们要解决的实现细节,不该外泄成用户负担。
*
* ⚠️ 为什么不在请求时去调 Open-Meteo 的地理编码接口(那也能查中文地名):
* 实测它对中国**地级市**的命中率很差 —— 中文名「长沙」「汕头」「湛江」「荆门」
* 「泸州」全部查不到(返回的是同名村/镇,甚至第一版查「青岛」返回的
* 第一条在**辽宁**)。要靠它就得先猜拼音、再按省份过滤,一轮下来仍是不可靠的。
* 离线表是确定的:写死的东西不会哪天变成另一个省。
*
* ⚠️ 表里的坐标是**逐条核对过**的(生成脚本 _tooling/_fb_geo_gen.py:
* 用拼音查 Open-Meteo 并按省份过滤,输出每条的 admin1 供人工核对,
* 个别被同名小镇盖住的如「淮安」取公开的城区坐标)。
* 改表请重跑那个脚本,别手敲坐标。
*/
declare(strict_types=1);
namespace App;
final class Geo
{
/**
* 直辖市 / 地级市 → [纬度, 经度, 省份]
*
* @var array<string, array{0:float,1:float,2:string}>
*/
public const CITIES = [
'北京' => [39.9075, 116.3972, '北京市'],
'上海' => [31.2222, 121.4581, '上海市'],
'天津' => [39.1422, 117.1767, '天津市'],
'重庆' => [29.5603, 106.5577, '重庆市'],
'石家庄' => [38.0414, 114.4786, '河北省'],
'唐山' => [39.6438, 118.1832, '河北省'],
'保定' => [38.8729, 115.4625, '河北省'],
'廊坊' => [39.5208, 116.7147, '河北省'],
'秦皇岛' => [39.9410, 119.5894, '河北省'],
'沧州' => [38.3112, 116.8533, '河北省'],
'邯郸' => [36.6100, 114.4876, '河北省'],
'邢台' => [37.0622, 114.4927, '河北省'],
'张家口' => [40.7834, 114.8714, '河北省'],
'承德' => [40.9519, 117.9588, '河北省'],
'衡水' => [37.7391, 115.6835, '河北省'],
'太原' => [37.8694, 112.5603, '山西省'],
'大同' => [40.0936, 113.2914, '山西省'],
'临汾' => [36.0889, 111.5189, '山西省'],
'运城' => [35.0231, 110.9928, '山西省'],
'长治' => [34.8659, 110.3957, '山西省'],
'晋中' => [37.6840, 112.7547, '山西省'],
'呼和浩特' => [40.8106, 111.6522, '内蒙古自治区'],
'包头' => [40.6516, 109.8439, '内蒙古自治区'],
'鄂尔多斯' => [39.6086, 109.7816, '内蒙古自治区'],
'赤峰' => [42.2683, 118.9636, '内蒙古自治区'],
'沈阳' => [41.7922, 123.4328, '辽宁省'],
'大连' => [38.9122, 121.6022, '辽宁省'],
'鞍山' => [41.1236, 122.9900, '辽宁省'],
'抚顺' => [41.8867, 123.9436, '辽宁省'],
'本溪' => [41.2886, 123.7650, '辽宁省'],
'丹东' => [40.1292, 124.3947, '辽宁省'],
'锦州' => [41.1078, 121.1417, '辽宁省'],
'营口' => [40.6647, 122.2318, '辽宁省'],
'盘锦' => [41.1210, 122.0739, '辽宁省'],
'葫芦岛' => [40.7524, 120.8355, '辽宁省'],
'朝阳' => [40.4844, 123.5369, '辽宁省'],
'长春' => [43.8800, 125.3228, '吉林省'],
'吉林' => [43.8465, 126.5608, '吉林省'],
'四平' => [43.1614, 124.3778, '吉林省'],
'通化' => [41.7197, 125.9264, '吉林省'],
'延吉' => [42.8882, 129.5024, '吉林省'],
'哈尔滨' => [45.7500, 126.6500, '黑龙江省'],
'齐齐哈尔' => [47.3392, 123.9615, '黑龙江省'],
'大庆' => [46.5833, 125.0000, '黑龙江省'],
'牡丹江' => [44.5480, 129.6259, '黑龙江省'],
'佳木斯' => [46.7971, 130.3112, '黑龙江省'],
'南京' => [32.0617, 118.7778, '江苏省'],
'苏州' => [31.3041, 120.5954, '江苏省'],
'无锡' => [31.5689, 120.2886, '江苏省'],
'徐州' => [34.2044, 117.2839, '江苏省'],
'常州' => [31.7736, 119.9540, '江苏省'],
'南通' => [32.0303, 120.8747, '江苏省'],
'扬州' => [32.3972, 119.4358, '江苏省'],
'盐城' => [33.3575, 120.1573, '江苏省'],
'泰州' => [32.4907, 119.9081, '江苏省'],
'镇江' => [32.2109, 119.4551, '江苏省'],
'淮安' => [33.5975, 119.0211, '江苏省'],
'连云港' => [34.5984, 119.2156, '江苏省'],
'宿迁' => [33.9492, 118.2958, '江苏省'],
'杭州' => [30.2936, 120.1614, '浙江省'],
'宁波' => [29.8782, 121.5494, '浙江省'],
'温州' => [27.9994, 120.6668, '浙江省'],
'嘉兴' => [30.7522, 120.7500, '浙江省'],
'绍兴' => [30.0024, 120.5786, '浙江省'],
'台州' => [28.6627, 121.4331, '浙江省'],
'金华' => [29.1068, 119.6442, '浙江省'],
'湖州' => [30.8703, 120.0933, '浙江省'],
'衢州' => [28.9594, 118.8686, '浙江省'],
'丽水' => [28.4604, 119.9103, '浙江省'],
'舟山' => [29.9887, 122.2049, '浙江省'],
'合肥' => [31.8639, 117.2808, '安徽省'],
'芜湖' => [31.3526, 118.4295, '安徽省'],
'蚌埠' => [32.9408, 117.3608, '安徽省'],
'安庆' => [30.5136, 117.0472, '安徽省'],
'马鞍山' => [31.6858, 118.5101, '安徽省'],
'淮南' => [32.6264, 116.9969, '安徽省'],
'黄山' => [29.7114, 118.3125, '安徽省'],
'滁州' => [32.3219, 118.2978, '安徽省'],
'阜阳' => [32.9000, 115.8167, '安徽省'],
'宿州' => [33.6361, 116.9789, '安徽省'],
'六安' => [31.7356, 116.5169, '安徽省'],
'亳州' => [33.8772, 115.7703, '安徽省'],
'福州' => [26.0614, 119.3061, '福建省'],
'厦门' => [24.4798, 118.0819, '福建省'],
'泉州' => [24.9139, 118.5858, '福建省'],
'漳州' => [24.5133, 117.6556, '福建省'],
'莆田' => [25.4394, 119.0103, '福建省'],
'三明' => [26.2486, 117.6186, '福建省'],
'南平' => [26.6450, 118.1736, '福建省'],
'龙岩' => [25.0749, 117.0178, '福建省'],
'宁德' => [26.6617, 119.5228, '福建省'],
'南昌' => [28.6840, 115.8531, '江西省'],
'九江' => [29.7048, 116.0021, '江西省'],
'赣州' => [25.8466, 114.9326, '江西省'],
'景德镇' => [29.2947, 117.2079, '江西省'],
'上饶' => [28.4518, 117.9429, '江西省'],
'宜春' => [27.8333, 114.4000, '江西省'],
'吉安' => [27.1172, 114.9793, '江西省'],
'抚州' => [27.9600, 116.3333, '江西省'],
'济南' => [36.6683, 116.9972, '山东省'],
'青岛' => [36.0649, 120.3804, '山东省'],
'烟台' => [37.4765, 121.4408, '山东省'],
'威海' => [37.5091, 122.1136, '山东省'],
'潍坊' => [36.7100, 119.1019, '山东省'],
'淄博' => [36.7906, 118.0633, '山东省'],
'临沂' => [35.0631, 118.3428, '山东省'],
'济宁' => [35.4050, 116.5814, '山东省'],
'泰安' => [36.1853, 117.1200, '山东省'],
'日照' => [35.4141, 119.5291, '山东省'],
'东营' => [37.4627, 118.4917, '山东省'],
'滨州' => [37.3667, 118.0167, '山东省'],
'德州' => [37.4466, 116.3671, '山东省'],
'聊城' => [36.4506, 116.0025, '山东省'],
'菏泽' => [35.2393, 115.4736, '山东省'],
'枣庄' => [34.8647, 117.5542, '山东省'],
'郑州' => [34.7578, 113.6486, '河南省'],
'洛阳' => [34.6735, 112.4368, '河南省'],
'开封' => [34.7986, 114.3074, '河南省'],
'新乡' => [35.1903, 113.8015, '河南省'],
'安阳' => [36.0960, 114.3828, '河南省'],
'焦作' => [35.2392, 113.2391, '河南省'],
'平顶山' => [33.7309, 113.3155, '河南省'],
'许昌' => [34.0319, 113.8630, '河南省'],
'漯河' => [33.5639, 114.0427, '河南省'],
'三门峡' => [34.7808, 111.1929, '河南省'],
'南阳' => [33.0052, 112.5466, '河南省'],
'商丘' => [34.4143, 115.6561, '河南省'],
'信阳' => [32.1228, 114.0656, '河南省'],
'周口' => [33.6333, 114.6333, '河南省'],
'驻马店' => [32.9794, 114.0294, '河南省'],
'武汉' => [30.5833, 114.2667, '湖北省'],
'宜昌' => [30.7144, 111.2847, '湖北省'],
'襄阳' => [32.0422, 112.1448, '湖北省'],
'荆州' => [30.3503, 112.1903, '湖北省'],
'黄石' => [30.2471, 115.0481, '湖北省'],
'十堰' => [32.6475, 110.7781, '湖北省'],
'孝感' => [30.9269, 113.9222, '湖北省'],
'荆门' => [31.0336, 112.2047, '湖北省'],
'长沙' => [28.1987, 112.9709, '湖南省'],
'株洲' => [27.8333, 113.1500, '湖南省'],
'湘潭' => [27.8500, 112.9000, '湖南省'],
'衡阳' => [26.8895, 112.6189, '湖南省'],
'岳阳' => [29.3745, 113.0948, '湖南省'],
'常德' => [29.0321, 111.6984, '湖南省'],
'郴州' => [25.8000, 113.0333, '湖南省'],
'张家界' => [29.1294, 110.4783, '湖南省'],
'广州' => [23.1167, 113.2500, '广东省'],
'深圳' => [22.5455, 114.0683, '广东省'],
'珠海' => [22.2769, 113.5678, '广东省'],
'汕头' => [23.3549, 116.6788, '广东省'],
'佛山' => [23.0268, 113.1315, '广东省'],
'东莞' => [23.0180, 113.7487, '广东省'],
'中山' => [22.5231, 113.3791, '广东省'],
'惠州' => [23.1115, 114.4152, '广东省'],
'江门' => [22.5833, 113.0833, '广东省'],
'湛江' => [21.2339, 110.3875, '广东省'],
'肇庆' => [23.0489, 112.4609, '广东省'],
'茂名' => [21.6663, 110.9136, '广东省'],
'揭阳' => [23.5418, 116.3658, '广东省'],
'潮州' => [23.6540, 116.6226, '广东省'],
'梅州' => [24.2886, 116.1177, '广东省'],
'河源' => [23.7333, 114.6833, '广东省'],
'清远' => [23.7000, 113.0333, '广东省'],
'韶关' => [24.8000, 113.5833, '广东省'],
'阳江' => [21.8556, 111.9627, '广东省'],
'云浮' => [22.9279, 112.0381, '广东省'],
'南宁' => [22.8167, 108.3167, '广西壮族自治区'],
'桂林' => [25.2802, 110.2964, '广西壮族自治区'],
'柳州' => [24.3240, 109.4070, '广西壮族自治区'],
'北海' => [21.4835, 109.1155, '广西壮族自治区'],
'海口' => [20.0342, 110.3465, '海南省'],
'三亚' => [18.2543, 109.5095, '海南省'],
'成都' => [30.6667, 104.0667, '四川省'],
'绵阳' => [31.4678, 104.6817, '四川省'],
'德阳' => [31.1302, 104.3820, '四川省'],
'宜宾' => [28.7593, 104.6399, '四川省'],
'泸州' => [28.8903, 105.4257, '四川省'],
'南充' => [30.7951, 106.0847, '四川省'],
'乐山' => [29.5623, 103.7639, '四川省'],
'贵阳' => [26.5833, 106.7167, '贵州省'],
'遵义' => [27.6867, 106.9072, '贵州省'],
'昆明' => [25.0389, 102.7183, '云南省'],
'大理' => [25.5847, 100.2123, '云南省'],
'丽江' => [26.8688, 100.2207, '云南省'],
'曲靖' => [25.4833, 103.7833, '云南省'],
'拉萨' => [29.6500, 91.1000, '西藏自治区'],
'西安' => [34.3378, 108.7026, '陕西省'],
'宝鸡' => [34.3678, 107.2370, '陕西省'],
'咸阳' => [34.3378, 108.7026, '陕西省'],
'渭南' => [34.5035, 109.5089, '陕西省'],
'榆林' => [38.2918, 109.7375, '陕西省'],
'兰州' => [36.0570, 103.8399, '甘肃省'],
'天水' => [34.5795, 105.7424, '甘肃省'],
'西宁' => [36.6255, 101.7574, '青海省'],
'银川' => [38.4681, 106.2731, '宁夏回族自治区'],
'乌鲁木齐' => [43.8010, 87.6005, '新疆维吾尔自治区'],
'喀什' => [39.4672, 75.9868, '新疆维吾尔自治区'],
];
/**
* 找坐标。返回 ['lat' => '36.0649', 'lon' => '120.3804', 'place' => '山东省青岛'] 或 null。
*
* 接受:完整市名(青岛) / 带「市」的(青岛市) / 带省份前缀的(山东省青岛)。
* 不做模糊匹配 —— "猜错城市"比"没有城市"糟得多(会让人以为天气坏了)。
*/
public static function lookup(string $q): ?array
{
$name = self::normalize($q);
if ($name === '' || !isset(self::CITIES[$name])) {
return null;
}
[$lat, $lon, $prov] = self::CITIES[$name];
return [
'lat' => number_format($lat, 4, '.', ''),
'lon' => number_format($lon, 4, '.', ''),
'place' => self::label($prov, $name),
];
}
/**
* 归一化:去空白 → 去末尾「市」→ 去省份前缀。
*
* ⚠️ 顺序很重要:**先**去「市」再去省份前缀。
* 反过来的话「上海市」会被当成"省份=上海市 + 空市名",
* 前缀一剥就什么都不剩了 → 直辖市全都查不到(北京/上海/天津/重庆)。
*
* ⚠️ 去省份用"已知省名清单"前缀匹配,而不是"取最后两个字":
* 「内蒙古」是三个字、「新疆」是两个字,按长度切必然切错。
*/
public static function normalize(string $q): string
{
$q = trim(preg_replace('/\s+/u', '', $q));
if ($q === '') {
return '';
}
// ① 末尾的「市」去掉(表里存的是不带「市」的名字)
if (mb_substr($q, -1, 1, 'UTF-8') === '市' && mb_strlen($q, 'UTF-8') > 1) {
$q = (string)mb_substr($q, 0, mb_strlen($q, 'UTF-8') - 1, 'UTF-8');
}
// ② 「山东省青岛」/「河北石家庄」这类带省份的写法,去掉省份前缀
foreach (self::PROVINCE_WORDS as $pw) {
if ($pw !== '' && mb_strpos($q, $pw) === 0 && mb_strlen($q, 'UTF-8') > mb_strlen($pw, 'UTF-8')) {
$q = (string)mb_substr($q, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
break;
}
}
return $q;
}
/**
* 展示用的地名:省份简称 + 市名(如「山东·青岛」),直辖市只说市名。
*
* ⚠️ 判据不能写成「市名只有两个字就是直辖市」——「青岛」「大连」「厦门」
* 「深圳」都是两个字,那样会把它们漏成光秃秃的「青岛」,
* 而没人在乎「北京」是不是直辖市,大家在乎的是"我看到的天气是哪儿的"。
*/
private static function label(string $prov, string $city): string
{
$short = self::PROV_SHORT[$prov] ?? '';
if ($short === '') {
return $city; // 直辖市 / 认不出的省份:只说市名
}
return $short . '·' . $city;
}
/** 完整省份名 → 简称(直辖市映射到空串 = 不加前缀) */
private const PROV_SHORT = [
'北京市' => '', '天津市' => '', '上海市' => '', '重庆市' => '',
'河北省' => '河北', '山西省' => '山西', '辽宁省' => '辽宁', '吉林省' => '吉林',
'黑龙江省' => '黑龙江', '江苏省' => '江苏', '浙江省' => '浙江', '安徽省' => '安徽',
'福建省' => '福建', '江西省' => '江西', '山东省' => '山东', '河南省' => '河南',
'湖北省' => '湖北', '湖南省' => '湖南', '广东省' => '广东', '海南省' => '海南',
'四川省' => '四川', '贵州省' => '贵州', '云南省' => '云南', '陕西省' => '陕西',
'甘肃省' => '甘肃', '青海省' => '青海', '台湾省' => '台湾',
'内蒙古自治区' => '内蒙古', '广西壮族自治区' => '广西',
'西藏自治区' => '西藏', '宁夏回族自治区' => '宁夏',
'新疆维吾尔自治区' => '新疆',
];
/** 支持"省份前缀"写法的省名清单(长的排前面,避免「山西」被「山」抢先匹配) */
private const PROVINCE_WORDS = [
'新疆维吾尔自治区', '广西壮族自治区', '宁夏回族自治区', '内蒙古自治区',
'西藏自治区', '香港特别行政区', '澳门特别行政区',
'黑龙江省', '内蒙古', '新疆', '广西', '宁夏', '西藏',
'北京市', '天津市', '上海市', '重庆市',
'河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省',
'福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省',
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
}