Files
FamilyBook/app/PersonQuery.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

143 lines
5.4 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 第 2 条)。
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这里只负责**服务端那一半**:把一份人员行按查询词筛一遍。
* 前端「输入即筛选」那一半在 assets/js/filter.js(纯 DOM 显隐,不发请求)。
*
* 为什么要把这段抽出来(而不是各页各写一遍):
* · 名册(Person::roster)、家里已有的人(Person::all)、以及以后任何"找个人"的地方,
* 判据必须**完全一致** —— 否则同一句「yyq」在名册里能搜到、在挑人页搜不到,
* 用户只会觉得"搜索坏了",而不会想到是两处实现不一样。
* · 上半段那个「要不要给 SQL 加 LIKE」的判断很容易写错(本项目已经错过一次):
* 纯字母(yyq)与纯数字(手机号)**都不能**交给 SQL 的 LIKE ——
* 前者 SQL 里没法把汉字转拼音,后者手机号是密文存的,一 LIKE 结果就成空。
*
* 分工:
* · needsSql() / likeArgs() → SQL 侧的**粗筛**(只在能帮忙时才加条件)
* · refine() → PHP 侧的**精筛**(一律跑,结果才是最终答案)
* · decorate() → 把「解密后的手机号 / 生日 / 我怎么称呼他」补齐给列表用
*/
declare(strict_types=1);
namespace App;
final class PersonQuery
{
/**
* 这个查询词该不该让 SQL 帮忙粗筛?
*
* 只有**库里有明文可比**的查询词值得:中文姓名、邮箱里的字母+符号。
* ⚠️ 纯字母(yyq = 姓名首字母)与纯数字(手机号)一律交给 PHP 精筛:
* · SQL 里没有"汉字→拼音"的函数,对 yyq 做 LIKE 必然 0 行;
* · 手机号是 Crypto 加密后存的(phone_encrypted),明文数字 SQL 永远匹配不到。
* 这两类一旦加了 SQL 条件,精筛阶段就已经没有数据可筛了,
* 症状是"搜 yyq 一个人都没有" —— 看着像功能没做。
*/
public static function needsSql(string $q): bool
{
$q = trim($q);
if ($q === '') {
return false;
}
return (bool)preg_match('/[^A-Za-z0-9]/u', $q);
}
/** SQL 粗筛要绑的参数(name / email / nickname 三个 LIKE) */
public static function likeArgs(string $q): array
{
$like = '%' . trim($q) . '%';
return [$like, $like, $like];
}
/** SQL 粗筛的 WHERE 片段(只在 needsSql() 为真时调用) */
public const SQL_WHERE = ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
/**
* PHP 精筛:姓名 / 我怎么称呼他 / 库里的旧小名 / 邮箱 / 解密后的手机号 / 姓名首字母。
*
* ⚠️ 进来之前**必须**先给每行补上 phone_plain(见 phonePlain()),
* 否则手机号这一路永远匹配不上(Pinyin::matches 认的是 phone_plain)。
*/
public static function refine(array $rows, string $q): array
{
$q = trim($q);
if ($q === '') {
return $rows;
}
$out = [];
foreach ($rows as $row) {
if (Pinyin::matches($row, $q)) {
$out[] = $row;
}
}
return array_values($out);
}
/**
* 解密手机号(失败一律当"没填",绝不让一行坏数据把整页筛挂)。
* @return string 明文;没有/解不开返回 ''
*/
public static function phonePlain(array $row): string
{
$enc = (string)($row['phone_encrypted'] ?? '');
if ($enc === '') {
return '';
}
try {
return (string)Crypto::decrypt($enc);
} catch (\Throwable $ex) {
return '';
}
}
/**
* 给列表用的行补上派生字段。幂等(已有值就不覆盖)。
*
* @param int $viewerId 谁在看(决定"我怎么称呼他")
* @param int $meId 用来标"这是我"(0=不标)
*/
public static function decorate(array $rows, int $viewerId, int $meId = 0): array
{
$graph = null;
$people = null;
foreach ($rows as &$row) {
if (!array_key_exists('phone_plain', $row)) {
$row['phone_plain'] = self::phonePlain($row);
}
if (!isset($row['shown'])) {
$row['shown'] = Nickname::display($viewerId, $row) ?: (string)($row['name'] ?? '');
}
}
unset($row);
return $rows;
}
/**
* 生日展示串:优先阳历(真实生日),没有就农历。带上**年份**(用户明确要求)。
*
* @return array{0:string,1:string} [显示串, '阳历'|'农历'|'']
*/
public static function birthdayOf(array $row): array
{
$solar = (string)Birthday::solarOf($row);
$d = Birthday::dateOnly($solar);
if ($d !== '') {
return [self::ymd($d), '阳历'];
}
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if ($lun !== '') {
return [self::ymd($lun), '农历'];
}
return ['', ''];
}
/** '1998-03-07' → '1998-03-07'(保持 YYYY-MM-DD,用户要求带年份) */
private static function ymd(string $d): string
{
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) ? $d : '';
}
}