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
+100 -48
View File
@@ -109,17 +109,24 @@ $ledMe = (int)($me ?? 0);
选中「存款」后,净值仍代表整本账,避免屏幕上只剩一个类别、
却被误读成"我家只有存款"。选中的类别是谁,由下面那条高亮条说明。 */ ?>
<div class="ck-filter led-kinds" data-ck-filter>
<?php /* ⚠️ v24 第 25 批 用户第 7 条:标签改成**多选**(互不排斥)。
用户原话:「那几个标签是可以多选的,它们之间不是互斥的,我选中这个标签,
就开始统计这个类别,我再点一次,就取消选择,不统计这个类别。」
⇒ 所以这里的「全部」不再是一颗"选项",而是**一键清空选择**的复位键。
文案写成「全部,点这里复位」比只写「全部」更能说明它不是同类选项。 */ ?>
<button type="button" class="ck-chip is-on" data-ck-kind=""
data-ck-sum="<?= e($money($summary['net'])) ?>"
data-ck-n="<?= (int)$summary['count'] ?>"
data-ck-word="整本账">全部(<?= (int)$summary['count'] ?>)</button>
data-ck-word="整本账"
title="清空选择,看整本账">全部(<?= (int)$summary['count'] ?>)</button>
<?php foreach ($summary['by_kind'] as $k => $c): ?>
<button type="button"
class="ck-chip led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>"
data-ck-kind="<?= e((string)$k) ?>"
data-ck-sum="<?= e($money((int)$c)) ?>"
data-ck-n="<?= (int)($summary['by_kind_n'][$k] ?? 0) ?>"
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>">
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>"
title="点一下开始统计「<?= e(\App\Ledger::kindWord((string)$k)) ?>」,再点一下取消">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
<b><?= e($money((int)$c)) ?></b>
</button>
@@ -560,22 +567,33 @@ $ledMe = (int)($me ?? 0);
现在点击后没有任何效果,这个需要做处理,点击任何一个标签,
则统计这个标签下的所有财务条目的总数,并在上下两个图上,画出来。」
🔴🔴 v24 第 25 批 用户第 7 条:**改成多选(标签之间不互斥)**。
用户原话:「财务的那几个标签,点击之后图片变化的时候,那几个标签是可以多选的,
它们之间不是互斥的,我选中这个标签,就开始统计这个类别,
我再点一次,就取消选择,不统计这个类别。」
⇒ 语义从「选中**一个**类别」变成「持有一个**选中的集合**」:
· 点一个没选中的 → 加进集合(开始统计这一类)
· 点一个已选中的 → 从集合里去掉(不再统计)
· 集合为空 = 等同「全部」(这比"一个都不显示"友好:
用户取消了最后一个类别时,看到的是回到全貌,而不是一张空图)
· 「全部」那颗按钮 = 一键清空集合(不是"再点一下取消它自己")
之所以不是"一个都不选 = 什么都不统计":`空集`与`全选`在图上看是同一件事
(所有线都画出来),而"点掉最后一个 → 整张图空掉"看着像坏了。
一处逻辑,四件事同时做(缺一件用户就会觉得"没生效"):
① 标签高亮(`.is-on`);
② 「总金额」图切到该类别的曲线(`[data-ck-tkind]`,都预渲染好了);
③ 多线图里只留该类别的线(`[data-ck-line]`)+ 图例只留对应项;
④ 账目表只列该类条目(`[data-ck-row]`)。
另外在概览卡里给一条高亮:看的是哪一类、合计多少、几笔,并给「清除筛选」。
② 「总金额」图:集合里**只剩一个**类别时切到该类的单线图,
否则显示整本账(多选时逐类单线图没有意义 —— 那是"把几条线并起来",
而多线图已经在做这件事了);
③ 多线图里只留**集合内**类别(`[data-ck-line]`)+ 图例只留对应项;
④ 账目表只列集合内类别(`[data-ck-row]`)。
另外在概览卡里给一条高亮,并给「清除筛选」。
⚠️ 两处 `.ck-filter`(概览卡的标签 + 图表卡的 chip)共用本函数 ——
它们是**同一个筛选器**的两个入口。如果只给其中一处绑事件,
点另一处就会"两处状态不一致"(一处亮着、图上却是另一个类别的结果)。
所以:任何一处的点击都会把两处的高亮 + 全部图表 + 表格一起重画。
⚠️ 图例项**不**用 hidden 藏:`.ck-lg-item[hidden]{display:none}` 的规则本来就有,
但"藏起来"会让图例的长度跳变。折中:藏(保持整齐一致),
并在下面的说明行里写清"只在看这一类"。
⚠️ 「全部」按钮的 data-ck-sum 用的是**净资产**(net),不是资产合计 ——
标签上那句话读起来是"整本账 = 多少钱",净资产才是那个数。 */
它们是**同一个筛选器**的两个入口,两处的高亮必须**完全同步**:
所以这里不重算"哪些该亮",而是**两处按同一个集合**刷一遍。
⚠️ 图例项**不**用 hidden 藏:折中成"藏",并在下面的说明行里写清"只看这几类"。 */
var bars = document.querySelectorAll('[data-ck-filter]');
if (bars.length) {
var focusBar = document.querySelector('[data-ck-focus]');
@@ -583,68 +601,97 @@ $ledMe = (int)($me ?? 0);
var noteAll = document.querySelector('[data-ck-note-all]');
var noteOne = document.querySelector('[data-ck-note-one]');
function applyKind(kind, sum, n, word) {
// ① 两处标签的高亮保持同步(同一个筛选器)
/** 当前选中的类别集合(空集 = 全部) */
var picked = [];
/** 读一遍某颗标签上带的说明数据(合计 / 笔数 / 类别名) */
function chipInfo(bar, kind) {
var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]');
return c ? {
sum: c.getAttribute('data-ck-sum') || '',
n: c.getAttribute('data-ck-n') || '',
word: c.getAttribute('data-ck-word') || '',
} : {sum: '', n: '', word: ''};
}
function reapply() {
// ① 两处标签的高亮保持同步(按同一个集合)
Array.prototype.forEach.call(bars, function (bar) {
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
c.classList.toggle('is-on', (c.getAttribute('data-ck-kind') || '') === kind);
var k = c.getAttribute('data-ck-kind') || '';
// 「全部」那颗:集合为空时亮(它就是"没有筛选"这个状态)
c.classList.toggle('is-on', k === '' ? picked.length === 0 : picked.indexOf(k) >= 0);
});
});
// ② 总金额图:只显示匹配的那一张
// ② 总金额图:只在"恰好选了一类"时切到那一类;多选或空集都显示整本账
var sole = picked.length === 1 ? picked[0] : '';
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-tkind]'), function (w) {
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== kind;
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== sole;
});
// 图上的标题跟着换(不然选着"存款",标题还写"净资产",自相矛盾)
var ttl = document.querySelector('[data-ck-total-title]');
if (ttl) {
ttl.textContent = kind === '' ? '总金额 · 净资产' : (word + ' · 金额变化');
var info = sole !== '' ? chipInfo(bars[0], sole) : null;
ttl.textContent = sole === '' ? '总金额 · 净资产'
: (info && info.word ? info.word : '') + ' · 金额变化';
}
// ③ 多线图:按类别显隐线条(几何不动,只切可见性)
// ③ 多线图:按集合显隐线条(几何不动,只切可见性)
//
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
// 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
// 必须同时给 `g` 与 `li`/`tr` 用同一种写法:
// · `<g>` 是 SVG → 只能 display
// · `.ck-lg-item` / `<tr>` 是 HTML,两样都行;统一用 display 少一个特例。
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
function keep(k) {
// 空集 = 全部(都留);否则只留在集合里的
return picked.length === 0 || picked.indexOf(k) >= 0;
}
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) {
g.style.display = (kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind)
? 'none' : '';
g.style.display = keep(g.getAttribute('data-ck-line') || '') ? '' : 'none';
});
Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) {
li.style.display = (kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind)
? 'none' : '';
li.style.display = keep(li.getAttribute('data-ck-kind') || '') ? '' : 'none';
});
// ④ 账目表
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) {
tr.style.display = (kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind)
? 'none' : '';
tr.style.display = keep(tr.getAttribute('data-ck-row') || '') ? '' : 'none';
});
// ⑤ 概览卡里的高亮条
// ⑤ 概览卡里的高亮条 + 图下的说明
if (focusBar && focusTxt) {
focusBar.hidden = (kind === '');
if (kind !== '') {
focusTxt.textContent = '正在看「' + word + '」:合计 ' + sum + ' · ' + n + ' 笔';
focusBar.hidden = (picked.length === 0);
if (picked.length > 0) {
var words = [];
var sumAll = 0, nAll = 0;
picked.forEach(function (k) {
var i = chipInfo(bars[0], k);
words.push(i.word);
// 合计:把每个标签上那串金额里的数字抠出来相加(金额是带千分位的字符串)
var digits = String(i.sum).replace(/[^0-9.\-]/g, '');
if (digits !== '' && !isNaN(parseFloat(digits))) { sumAll += parseFloat(digits); }
nAll += parseInt(i.n || '0', 10) || 0;
});
// 合计只对"金额可直接相加"的类别准确(负债类在同一本账里是负数),
// 所以这句说明里把笔数写在前、合计写在后,别让用户把它当成权威口径。
focusTxt.textContent = '正在看 ' + picked.length + ' 类(' + words.join('、') + '):'
+ nAll + ' 笔';
}
}
if (noteAll && noteOne) {
noteAll.hidden = (kind !== '');
noteOne.hidden = (kind === '');
if (kind !== '') {
noteOne.textContent = '只画了「' + word + '」这一类(' + n + ' 条线),'
+ '合计 ' + sum + ';点「清除筛选」看整本账。';
noteAll.hidden = (picked.length > 0);
noteOne.hidden = (picked.length === 0);
if (picked.length > 0) {
var ws = [];
picked.forEach(function (k) { ws.push(chipInfo(bars[0], k).word); });
noteOne.textContent = '只画了「' + ws.join('、') + '」这 ' + picked.length + ' 类;'
+ '再点一次标签可以取消这一类,点「清除筛选」看整本账。';
}
}
}
@@ -655,17 +702,22 @@ $ledMe = (int)($me ?? 0);
var chip = t.closest('.ck-filter .ck-chip');
if (chip) {
e.preventDefault();
applyKind(chip.getAttribute('data-ck-kind') || '',
chip.getAttribute('data-ck-sum') || '',
chip.getAttribute('data-ck-n') || '',
chip.getAttribute('data-ck-word') || '');
var kind = chip.getAttribute('data-ck-kind') || '';
if (kind === '') {
picked = []; // 「全部」= 清空集合
} else {
var i = picked.indexOf(kind);
if (i >= 0) { picked.splice(i, 1); } // 再点一次 = 取消这一类
else { picked.push(kind); } // 点一次 = 开始统计这一类
}
reapply();
return;
}
// 清除筛选 = 回到"全部"那一档(不自己拼状态,直接找那颗按钮点它一次)
// 清除筛选 = 回到"全部"(不自己拼状态,直接清空集合)
if (t.closest('[data-ck-clear]')) {
e.preventDefault();
var all = document.querySelector('[data-ck-filter] .ck-chip[data-ck-kind=""]');
if (all) { all.click(); }
picked = [];
reapply();
}
});
}