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 无源码泄漏、错误日志无新增。
This commit is contained in:
2026-10-03 15:01:38 +08:00
parent dbcf0c1bcd
commit e0562b8627
31 changed files with 2891 additions and 505 deletions
+334
View File
@@ -0,0 +1,334 @@
<?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 = [
'新疆维吾尔自治区', '广西壮族自治区', '宁夏回族自治区', '内蒙古自治区',
'西藏自治区', '香港特别行政区', '澳门特别行政区',
'黑龙江省', '内蒙古', '新疆', '广西', '宁夏', '西藏',
'北京市', '天津市', '上海市', '重庆市',
'河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省',
'福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省',
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
}