Files
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

321 lines
13 KiB
PHP
Raw Permalink 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
/**
* 全国行政区划(省 / 地级市 / 区县三级)。
*
* 用户 2026-10-04 第 4 条:「我们的地址,应该也是这样的,由:国家、省、市、区和详细地址组成。
* 如果没有非常详细的地址,则只记录国家、省、市和区,
* 需要把全国的行政区划,写入数据库中进行对应。」
*
* 为什么要有这张表:
* · 通讯录导进来的地址是一整串自由文本(「山东省青岛市市南区香港中路 76 号」),
* 不拆开就只能整串存 —— 于是"按省找人""看看哪些人在青岛"永远做不了;
* · 手工填地址时,省/市/区要有**级联下拉**,否则"市"那一栏会出现
* 「青岛市」和「山东青岛市」两种写法,等于没有结构化;
* · 拆出来的每一级都要能**对得上标准名**,才能与导入的数据互相匹配
* (不然导入的和手填的是两套写法,仍然是两套)。
*
* 数据来源:`assets/data/regions.json`(3429 条:31 省 / 342 市 / 3056 区县),
* 安装与结构升级时灌进 `regions` 表 —— **文件是种子,库是权威**。
* ⚠️ 只灌一次(按 COUNT(*) 判空),重复灌不会重复也不会覆盖用户改动。
*
* ⚠️ 这里**只存中国大陆三级**。国家单独一个自由文本字段(addr_country):
* 国外的行政区划要维护到区县一级是另一件事,先不臆造。
*/
declare(strict_types=1);
namespace App;
final class Region
{
/** @var array<string,array{name:string,level:int,parent:string}>|null */
private static ?array $all = null;
private static function db(): \PDO
{
return Database::get();
}
/** 种子数据文件(相对站点根) */
public static function seedFile(): string
{
return dirname(__DIR__) . '/assets/data/regions.json';
}
/**
* 把种子数据灌进 regions 表。
*
* @return int 本次真的插入的条数(已灌过则返回 0)
*/
public static function seed(?\PDO $db = null): int
{
$db = $db ?: self::db();
$n = (int)$db->query('SELECT COUNT(*) FROM `regions`')->fetchColumn();
if ($n > 0) {
return 0;
}
$file = self::seedFile();
$raw = @file_get_contents($file);
if ($raw === false) {
return 0;
}
$rows = json_decode($raw, true);
if (!is_array($rows)) {
return 0;
}
// ⚠️ 一张表一次性灌 3429 条 —— 用事务 + 预编译,别一条一条 autocommit
// (三千次 fsync 会把安装拖到几十秒,看着像卡死)。
$db->beginTransaction();
// ⚠️ 用 INSERT IGNORE 而不是 INSERT:东莞、中山这类"市直管镇"的城市,
// 数据集里同一个 9 位代码会出现在两棵树下(区一级与镇一级),
// 用普通 INSERT 会在第 2170 行炸掉、把后面一千多条全丢掉
// (实测:库里只剩 19 个省,界面上"海南省之后全没了")。
// IGNORE 之后"先来的那条留下",总量与唯一性都对。
$st = $db->prepare(
'INSERT IGNORE INTO `regions` (`code`, `name`, `level`, `parent_code`) VALUES (?, ?, ?, ?)'
);
$done = 0;
foreach ($rows as $r) {
if (!is_array($r) || count($r) < 3) {
continue;
}
$code = (string)$r[0];
$name = (string)$r[1];
$parent = (string)$r[2];
$level = $parent === '' ? 1 : (strlen($parent) <= 2 ? 2 : 3);
$st->execute([$code, $name, $level, $parent === '' ? null : $parent]);
$done++;
}
$db->commit();
self::$all = null;
return $done;
}
/** 全表(一次请求内只读一次库) */
public static function all(): array
{
if (self::$all !== null) {
return self::$all;
}
$out = [];
try {
foreach (self::db()->query('SELECT code, name, level, parent_code FROM `regions`')->fetchAll() as $r) {
$out[(string)$r['code']] = [
'name' => (string)$r['name'],
'level' => (int)$r['level'],
'parent' => (string)($r['parent_code'] ?? ''),
];
}
} catch (\Throwable $ex) {
$out = []; // 表还没建好(极早期):调用方拿到空列表,不至于整页 500
}
self::$all = $out;
return $out;
}
/** 省级列表:[['code'=>..,'name'=>..], ...] */
public static function provinces(): array
{
$out = [];
foreach (self::all() as $code => $r) {
if ($r['level'] === 1) {
$out[] = ['code' => (string)$code, 'name' => $r['name']];
}
}
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
return $out;
}
/** 某级的下级列表(省→市、市→区县) */
public static function childrenOf(string $code): array
{
if ($code === '') {
return self::provinces();
}
$out = [];
foreach (self::all() as $c => $r) {
if ($r['parent'] === $code) {
$out[] = ['code' => $c, 'name' => $r['name']];
}
}
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
return $out;
}
/** 代码 → 标准名 */
public static function nameOf(?string $code): string
{
$code = (string)$code;
if ($code === '') {
return '';
}
$all = self::all();
return $all[$code]['name'] ?? '';
}
/** 代码 → 上级代码(往上找两级,用来回填「省」) */
public static function parentOf(?string $code): string
{
$code = (string)$code;
$all = self::all();
return (string)($all[$code]['parent'] ?? '');
}
/**
* 把一整串自由文本地址拆成结构化的五段。
*
* 判据是**从前往后逐级前缀匹配**:先认省、再在它下面认市、再在市下面认区县,
* 剩下的整串当详细地址。之所以只能这么干:中文地址没有分隔符,
* 「山东省青岛市市南区香港中路76号」全靠"哪个标准名出现在开头"来切。
*
* ⚠️ 匹配要对**省/市/自治区的后缀**做容错:「山东省」和「山东」都要认,
* 「青岛市」和「青岛」都要认 —— 用户手填、通讯录导出、快递地址三种写法都不一样。
*
* @return array{country:?string,province:?string,city:?string,district:?string,detail:?string}
*/
public static function parse(string $text): array
{
$out = ['country' => null, 'province' => null, 'city' => null,
'district' => null, 'detail' => null];
$text = trim(preg_replace('/\s+/u', ' ', (string)$text) ?? '');
if ($text === '') {
return $out;
}
// ① 国家:只有**写在开头**的显式国家名才算(否则「中国」可能只是路名的一部分)
foreach (['中华人民共和国', '中国'] as $c) {
if (mb_strpos($text, $c) === 0) {
$out['country'] = '中国';
// ⚠️ 同下面 `parse()` 末尾那条注释:`ltrim(…, ' ,,')` 的 charlist
// **按字节**匹配,「,」= EF BC 8C → 字节 EF/BC/8C 也进了可剥集合,
// 会把以这些字节开头的汉字**剥掉首字节** → 非法 UTF-8。
// 改成字符级(`/u`)去空白与中文逗号。
$text = (string)preg_replace(
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($c)));
break;
}
}
$rest = $text;
// ② 省
foreach (self::provinces() as $p) {
$hit = self::matchPrefix($rest, $p['name']);
if ($hit !== null) {
$out['province'] = $p['name'];
$rest = $hit;
break;
}
}
// ③ 市:优先在已认出的省下面找;省没认出来就在全国范围找
$pool = $out['province'] !== null
? self::childrenOf(self::codeOfName($out['province'], 1))
: self::allCities();
foreach ($pool as $c) {
$hit = self::matchPrefix($rest, $c['name']);
if ($hit !== null) {
$out['city'] = $c['name'];
$rest = $hit;
break;
}
}
// ④ 区县:在已认出的市下面找
if ($out['city'] !== null) {
foreach (self::childrenOf(self::codeOfName($out['city'], 2)) as $d) {
$hit = self::matchPrefix($rest, $d['name']);
if ($hit !== null) {
$out['district'] = $d['name'];
$rest = $hit;
break;
}
}
}
/* 🔴🔴 这里**绝不能**写成 `trim($rest, " ,,。;;\t\n\r\0\x0B")`
* (v24 第 25 批 2026-10-07 在线上实测抓到的真 bug,且是**数据损坏级**的)。
*
* 根因:PHP 的 `trim()` 第二参数(charlist)是**按字节**匹配的,不是按字符。
* 上面那串里带三个**多字节**中文标点:
* 「,」= E3 80 8C、「。」= E3 80 82、「;」= EF BC 9B
* 于是字节集合里混进了 **80 / 82 / 8C / 9B / BC …** ——
* 任何一个**尾字节**恰好等于这些值的汉字,都会被\"剥掉最后一字节\":
* 「层」= E5 B1 82 → 被剥成 E5 B1(**非法 UTF-8**)
* 「,」类标点本身也会被剥掉前两字节,只剩 E3。
* 症状链条(线上实测):`backfillAddress` 拿这个坏 detail 去 UPDATE
* → `SQLSTATE[HY000] 1366 Incorrect string value: '\xE5\xB1' for column 'address'`
* → 升级步骤被 tryStep 捕获跳过 → **这条升级从上线起就没成功过**,
* 而且 request 每来一次就报一次错(日志一直刷,没人注意到)。
* ⚠️ 更阴的地方:它**只在特定数据上发作**(地址里带「层」「,」(半角在
* 中间不影响)等尾字节命中集合的字),本地夹具没有这种地址 → 本地全绿。
*
* 正确做法:先把中文标点(多字节)**按字符**去掉,剩下的 ASCII 空白再交给 trim。
* 顺序不能反 —— 先 trim 会把中文标点剥成半个字节,再去字符级就没救了。
*/
$rest = (string)preg_replace('/^[\s,,。;;、]+|[\s,,。;;、]+$/u', '', $rest);
if ($rest !== '') {
$out['detail'] = $rest;
}
return $out;
}
/** 全部地级市(省没认出来时的兜底池子) */
private static function allCities(): array
{
$out = [];
foreach (self::all() as $code => $r) {
if ($r['level'] === 2) {
$out[] = ['code' => (string)$code, 'name' => $r['name']];
}
}
// 长名优先:否则「市辖区」会先于「市南区」被匹配掉
usort($out, static fn($a, $b) => mb_strlen($b['name']) <=> mb_strlen($a['name']));
return $out;
}
/** 标准名 → 代码(限某一级,避免「市辖区」那种重名撞车) */
public static function codeOfName(?string $name, int $level): string
{
$name = trim((string)$name);
if ($name === '') {
return '';
}
foreach (self::all() as $code => $r) {
if ($r['level'] === $level && $r['name'] === $name) {
return (string)$code;
}
}
return '';
}
/**
* 看 $name 是不是 $text 的开头(容掉省/市/自治区这类后缀)。
* 命中返回**吃掉之后剩下的串**,没命中返回 null。
*/
private static function matchPrefix(string $text, string $name): ?string
{
$name = trim($name);
if ($name === '' || $text === '') {
return null;
}
// 「山东省」→ 也能认「山东」;「青岛市」→ 也能认「青岛」。
// ⚠️ 反过来不行(不能拿短名去吃长名),所以两种写法都试一遍。
$short = preg_replace('/(省|市|自治区|特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治州|地区|盟)$/u', '', $name);
$cands = [$name];
if ($short !== null && $short !== '' && $short !== $name) {
$cands[] = $short;
}
foreach ($cands as $cand) {
if (mb_strlen($cand) < 2) {
continue; // 太短会误吃(如「东区」)
}
if (mb_strpos($text, $cand) === 0) {
return (string)preg_replace(
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($cand)));
}
}
return null;
}
}