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() 不再报错。
This commit is contained in:
2026-10-07 11:35:31 +08:00
parent 09e30d27d4
commit a5e559b2b3
23 changed files with 2379 additions and 212 deletions
+106 -3
View File
@@ -543,10 +543,48 @@ final class Nav
return $host ? (string)preg_replace('/^www\./i', '', $host) : '';
}
/** 图标文件名(按 URL 取 md5,与 SimpleNav 一致) */
/**
* 图标键(按 URL 取 md5)—— **必须与 SimpleNav 的 `icon_key()` 逐字一致**。
*
* 🔴🔴 v24 第 25 批 用户第 9 条修(搬运失真,导致"图标全都没有"):
*
* 原实现写成了 `md5(self::normUrl($url))` —— 看起来"合理",但 SimpleNav 的
* `icon_key()` 做的归一化比 `normUrl()` 多得多,两者算出来的键**不一样**:
* · SimpleNav:scheme/host **转小写**、**补上空路径为 `/`**、保留 port/query/fragment;
* · 原来这里:什么都不做(只用 normUrl 补 `http://` 前缀)。
* 于是 `https://otrust.ouc.edu.cn`(无路径)在两边分别得到
* `921cbff230c762f21ec5b6ffbfa1da63` 与 `0c4227cc33e0b0ab680aaf24031965f3`
* —— **只要 URL 里有一个大写字母或缺一个斜杠,文件名就对不上**。
*
* 后果(用户看到的现象):从 SimpleNav 搬过来的站点,`icon` 列里存着的文件名
* 指向的文件其实叫另一个名字,`iconUrl()` 里 `is_file()` 判假 → 返回空串 →
* 前端退化成"首字母色块",**一个图标都显示不出来**。
*
* ⚠️ 另外注意:**实际文件名是 `md5(本函数的结果)`(二次哈希)** ——
* 这是 SimpleNav 的历史遗留(只为让文件名定长),但它确实在文件名里,
* 所以搬迁 / 读写图标文件时**不要**自己再哈希或去掉这一层。
* 见 NavFetch::download_best_icon 的落盘处(它用的是 `md5($key)`)。
*/
public static function iconKey(string $url): string
{
return md5(self::normUrl($url));
$u = trim($url);
if ($u === '') {
return '';
}
if (!preg_match('#^https?://#i', $u)) {
$u = 'http://' . $u;
}
$p = @parse_url($u);
if (!is_array($p) || empty($p['host'])) {
return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞
}
$norm = strtolower((string)($p['scheme'] ?? '')) . '://'
. strtolower((string)$p['host'])
. (empty($p['port']) ? '' : ':' . $p['port'])
. (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/')
. (isset($p['query']) ? '?' . $p['query'] : '')
. (isset($p['fragment']) ? '#' . $p['fragment'] : '');
return md5($norm);
}
/** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */
@@ -587,7 +625,72 @@ final class Nav
if (!is_file($abs)) {
return '';
}
return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs);
// 🔴 走 **nav_icon.php** 这个免登录公开出口,**不能**直接给 `uploads/…` 的地址:
// uploads/ 整个目录是 `Require all denied`(线上实测直接取会 403),
// 而展示页是免登录的、拿不到 media.php 需要的登录态。
// 详见 nav_icon.php 的文件头(两个约束为什么会撞在一起)。
// ⚠️ 管理页(登录态)也用同一条:一个图标地址两套出口,
// "管理页看得见、展示页看不见"这种半生效是最难查的。
return self::abs('/nav_icon.php') . '?f=' . rawurlencode($file) . '&v=' . filemtime($abs);
}
/**
* 把站内相对地址变成**绝对地址**(v24 第 25 批 用户第 9 条修)。
*
* 🔴🔴 为什么必须有这个函数(这是本批最要紧的一个根因):
*
* 家书的 `url()` 产出的是 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
* —— 全是**相对**地址(只有 `asset()` 是 `/assets/…` 绝对路径)。
* 它们在站点根(`https://host/index.php?...`)上没问题,
* 但**导航展示页在 `/nav/` 这个子目录下**:浏览器会把相对地址拼在**当前目录**上,
* 于是变成 `/nav/index.php?r=…`、`/nav/media.php?k=…` —— **线上实测全 404**。
*
* 症状正是用户报的那三条一起出现(同一个根因):
* · 展示页头像不显示(`/nav/media.php?k=avatar.jpg` → 404)
* · 点「登录」404(`/nav/index.php?r=auth/login` → 404)
* · 搜索联想请求打到 `/nav/…`(同样 404,下拉永远空)
*
* 修法:**凡是展示页要输出的自引用地址,一律经这里拼上 base_url**。
* ⚠️ 只在**免登录展示页**用得到(它是唯一不在站点根下的页面)——
* 不要拿它去替换全站的 `url()`:那会把所有站内链接都变成绝对地址,
* `?r=` 那套路由本来就不需要域名,改了反而多一处配置依赖。
*
* @param string $path 以 `/` 开头的站内路径,或 `index.php?r=…` 这种相对串
*/
public static function abs(string $path): string
{
$path = '/' . ltrim($path, '/');
/* 🔴 优先用**当前请求的 Host**,不要写死配置里的 base_url。
*
* 为什么(2026-10-07 第 25 批实测):本地真机探针起的是**随机端口**的
* `php -S 127.0.0.1:<port>`,而 `config.php` 的 `base_url` 写的是
* `http://127.0.0.1:8791`。展示页把 CSS 拼成 `…:8791/assets/nav-show.css`
* → 与探针那个端口对不上 → **样式整份取不到** →
* 症状是"登录按钮落到左边、头像判据也跟着偏",看起来像**布局坏了**,
* 其实是"资源地址指到了另一个端口"(最难查的一类:页面 200、HTML 全对)。
* ⇒ 绝对化要以"用户实际访问的那个 host"为准 —— 这也是**唯一正确**的口径:
* 线上有多个入口(域名 / IP / 宝塔测试域名)时,写死 base_url 会把
* 另外那些入口的静态资源全指到 base_url 那个域上去。
*
* ⚠️ 但 Host 头是**客户端可控**的(Host 注入)—— 所以只接受**形状合法**的
* host:字母数字点横线、可带端口。不合法就退回 base_url。
*/
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host !== '' && preg_match('/^[A-Za-z0-9.\-]+(:\d+)?$/', $host)) {
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'
|| (int)($_SERVER['SERVER_PORT'] ?? 0) === 443;
return ($https ? 'https://' : 'http://') . $host . $path;
}
$base = rtrim((string)fb_config('base_url', ''), '/');
if ($base === '') {
// 没配 base_url(极少数自建环境)→ 退化成"从站点根算"的绝对路径。
// 仍然比相对地址安全:相对地址在 /nav/ 下必错,而 /index.php 至少指对了。
return $path;
}
return $base . $path;
}
public static function deleteIconFile(string $file): void