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
+178 -19
View File
@@ -961,7 +961,18 @@ switch ($r) {
case 'people/group':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 25 批 用户第 1 条:「名册模块在列表中,对列表中的某一个人进行分组,
// 也不应该刷新界面,而是应该动态调整页面。」
// → 与 people/reorder 同一套做法:**AJAX 请求回 JSON**(前端就地挪卡片),
// 普通表单提交仍走 302 兜底(没有 JS 时照样能用)。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly',
'msg' => '你的身份是访客,不能改名册里的分组。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people');
}
@@ -970,16 +981,42 @@ switch ($r) {
$gid = (int)($_POST['group_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '找不到这个人。');
redirect('people');
}
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 200);
echo json_encode(['ok' => false,
'msg' => '这个人不在你的名册里,或者那个分组你用不了。'],
JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
redirect('people');
}
flash('ok', $gid > 0
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
: '已把「' . $p['name'] . '」改成未分类。');
$gname = $gid > 0 ? PersonGroup::name($familyId, $gid) : '';
$msg = $gid > 0
? '已把「' . $p['name'] . '」归到「' . $gname . '」。'
: '已把「' . $p['name'] . '」改成未分类。';
if ($isAjax) {
// ⚠️ 前端需要**分组名**去改那颗按钮上的文字("改成未分类"时是空串)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'pid' => $pid,
'gid' => $gid,
'gname' => $gname,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('people');
break;
@@ -3718,6 +3755,13 @@ switch ($r) {
$me = (int)Auth::personId();
// 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。
$sp = Anniversary::split($familyId, $me);
$people = Person::roster($familyId, $me, 'member', '');
// v24 第 25 批 用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」
// 模板按 id 取头像与称呼 → 这里先做成 id => 行 的映射(不必在模板里循环找人)。
$pmap = [];
foreach ($people as $pp) {
$pmap[(int)$pp['id']] = $pp;
}
View::render('days/index', [
'title' => '纪念日',
'custom' => $sp['custom'],
@@ -3725,7 +3769,8 @@ switch ($r) {
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'kinds' => Anniversary::kinds($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
'people' => $people,
'peopleMap' => $pmap,
], 'days');
break;
@@ -3778,6 +3823,12 @@ switch ($r) {
flash('err', '找不到这条纪念日。');
redirect('days');
}
// v24 第 25 批 用户第 2 条:编辑页要把**已关联的多个人**预勾选出来。
// 新表为空时回落到老列 person_id(升级过渡期的兜底)。
if ($editing) {
$pids = Anniversary::peopleOf($editing);
$editing['person_ids'] = $pids;
}
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
@@ -3791,6 +3842,12 @@ switch ($r) {
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
// v24 第 25 批 用户第 2 条:关联人改成**多值** `person_ids[]`。
// ⚠️ 兼容老表单:只传单个 `person_id` 时也认(见 Anniversary::save 的说明)。
$pids = (array)($_POST['person_ids'] ?? []);
if (!$pids && isset($_POST['person_id'])) {
$pids = [(int)$_POST['person_id']];
}
Anniversary::save($familyId, [
'title' => (string)($_POST['title'] ?? ''),
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
@@ -3799,7 +3856,7 @@ switch ($r) {
'is_leap' => !empty($_POST['is_leap']),
'kind' => (string)($_POST['kind'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
'person_ids' => array_map('intval', $pids),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
redirect('days');
@@ -5026,16 +5083,36 @@ switch ($r) {
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
// v24 第 25 批 用户第 6 条:复选框在**任何地方**都能点(首页/看板/详情),
// 所以这三处都可能发来 AJAX。失败也回 JSON,别让它落进"整页跳转"。
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '这项任务不能改。');
redirect('tasks');
}
Task::setStatus($familyId, $id, $status);
$word = Task::statusWord(Task::normalizeStatus($status));
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
flash('ok', '已标成「' . $word . '」,下一次的也排上了。');
} else {
flash('ok', '已标成「' . $word . '」。');
$msg = ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '')
? '已标成「' . $word . '」,下一次的也排上了。'
: '已标成「' . $word . '」。';
// v24 第 25 批:勾复选框 / 点状态徽标都走这条 ——
// 带 X-Requested-With 时回 JSON(前端就地改那张卡,不刷页);
// 普通表单提交照旧 302(没有 JS 时的兜底)。
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'id' => $id,
'status' => Task::normalizeStatus($status),
'word' => $word,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks',
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []);
break;
@@ -5166,6 +5243,17 @@ switch ($r) {
case 'books':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// ---- v24 第 25 批 用户第 8 条:「需要能够做到定期完成,自动同步」----
//
// ⚠️ 触发点放在**读书列表页**(而不只是"微信读书导入"那一页):
// 用户日常打开的是「读书」这个模块入口,导入页他一个月也去不了一次。
// 挂在导入页 = 自动同步基本永远不会被触发(等于没做)。
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
// 读一个 user_settings 时间戳,不联网。
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
WeRead::maybeSync($familyId, $me, false);
}
$f = [
'status' => (string)($_GET['status'] ?? ''),
'category' => (string)($_GET['cat'] ?? ''),
@@ -5535,6 +5623,25 @@ switch ($r) {
: (string)$r['error']);
redirect('books/weread');
}
// v24 第 25 批 用户第 8 条:自动同步的总开关(默认开)
if ($act === 'auto_toggle') {
WeRead::setAutoSync($me, !WeRead::autoSyncOn($me));
flash('ok', WeRead::autoSyncOn($me)
? '已打开自动同步(打开读书页时顺带同步,约每 6 小时一次)。'
: '已关闭自动同步,需要手动点「立即同步」。');
redirect('books/weread');
}
// 用户第 8 条:手动「立即同步」(忽略间隔,立刻打一次接口)
if ($act === 'sync_now') {
$r = WeRead::maybeSync((int)Auth::familyId(), $me, true);
if (!$r['ok']) {
flash('err', '同步失败:' . (string)$r['error']);
} else {
flash('ok', '同步完成:新增 ' . (int)$r['books'] . ' 本书、'
. (int)$r['covers'] . ' 张封面、' . (int)$r['notes'] . ' 条笔记。');
}
redirect('books/weread');
}
if ($act === 'fetch') {
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
$nb = WeRead::notebooks($me);
@@ -5545,10 +5652,14 @@ switch ($r) {
$preview = [];
foreach (array_slice($nb['books'], 0, 40) as $b) {
$nt = WeRead::notes($me, (string)$b['bookId']);
// v24 第 25 批 用户第 8 条:预览里也把**封面**带出来,
// 让用户选书时看得见是哪一本(原来只有一行文字)。
$info = WeRead::bookInfo($me, (string)$b['bookId']);
$preview[] = [
'title' => (string)$b['title'],
'author' => (string)$b['author'],
'bookId' => (string)$b['bookId'],
'cover' => $info['ok'] ? (string)($info['book']['cover'] ?? '') : '',
'count' => count((array)($nt['items'] ?? [])),
'items' => array_slice((array)($nt['items'] ?? []), 0, 3),
'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']),
@@ -5558,19 +5669,31 @@ switch ($r) {
'title' => '微信读书导入',
'preview' => $preview,
'nbCount' => count($nb['books']),
'stats' => WeRead::readStats($me, 'overall'),
], 'books');
break;
}
if ($act === 'import') {
// 「真的导入」:只导**勾选的那些书**(用户在预览页里挑)
$want = array_map('strval', (array)($_POST['pick'] ?? []));
$nb = WeRead::notebooks($me);
//
// ⚠️ v24 第 25 批 用户第 8 条:**不再自己写一遍导入逻辑** ——
// 原来这里只建 title + author + status(于是封面永远是空的),
// 现在整份交给 `WeRead::syncNow()`:它会把封面、简介、ISBN、
// 出版社、字数、进度**一次拿齐**,而且「只补空字段」。
// 两处各写一遍 = 迟早只修一处(这正是"封面看不到"的来路)。
$want = array_map('strval', (array)($_POST['pick'] ?? []));
if (!$want) {
flash('err', '没有勾选任何书。');
redirect('books/weread');
}
UserSetting::set($me, WeRead::SYNC_AT_KEY, '0'); // 强制真同步
$nb = WeRead::notebooks($me);
if (!$nb['ok']) {
flash('err', (string)$nb['error']);
redirect('books/weread');
}
$familyId = (int)Auth::familyId();
$sumBooks = $sumNotes = 0;
$sumBooks = $sumNotes = $sumCovers = 0;
foreach ($nb['books'] as $b) {
if (!in_array((string)$b['bookId'], $want, true)) {
continue;
@@ -5579,34 +5702,70 @@ switch ($r) {
if ($title === '') {
continue;
}
// 逐本走 syncNow 的那套「建书/补空」逻辑:把它当"只同步这一本"。
// ⚠️ syncNow 是按"有笔记的书"遍历的,这里已经有确定的 bookId,
// 所以借它内部那两个入口(bookInfo + fetchCover)手动走一遍,
// 保持与自动同步**完全同一条路径**(封面/元数据不会两样)。
$bid = WeRead::matchLocalBook($familyId, $title);
$info = WeRead::bookInfo($me, (string)$b['bookId']);
$meta = $info['ok'] ? $info['book'] : [];
$coverKey = '';
if (!empty($meta['cover'])) {
$coverKey = WeRead::fetchCover($familyId, (string)$meta['cover'], $title);
if ($coverKey !== '') { $sumCovers++; }
}
if ($bid <= 0) {
$bid = Book::create($familyId, $me, [
'title' => $title,
'author' => (string)$b['author'],
'status' => 'reading',
'title' => $title,
'author' => (string)($meta['author'] ?? $b['author']),
'translator' => (string)($meta['translator'] ?? ''),
'publisher' => (string)($meta['publisher'] ?? ''),
'published' => (string)($meta['published'] ?? ''),
'isbn' => (string)($meta['isbn'] ?? ''),
'category' => (string)($meta['category'] ?? ''),
'summary' => (string)($meta['intro'] ?? ''),
'words' => (int)($meta['wordCount'] ?? 0),
'rating' => (int)($meta['rating'] ?? 0) ?: null,
'cover_key' => $coverKey,
'status' => 'reading',
]);
if ($bid <= 0) {
continue;
}
$sumBooks++;
} elseif ($coverKey !== '') {
// 已在书架且没封面 → 补上(用户第 8 条「封面图看不到」)
$row = Book::find($familyId, $bid);
if ($row && trim((string)($row['cover_key'] ?? '')) === '') {
WeRead::patchBook($familyId, $bid, ['cover_key' => $coverKey]);
}
}
$nt = WeRead::notes($me, (string)$b['bookId']);
if (!$nt['ok']) {
continue;
if ($nt['ok']) {
$res = WeRead::store($bid, $me, (array)$nt['items']);
$sumNotes += (int)$res['added'];
}
$res = WeRead::store($bid, $me, (array)$nt['items']);
$sumNotes += (int)$res['added'];
}
flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、'
. $sumCovers . ' 张封面、'
. $sumNotes . ' 条笔记(重复的已自动跳过)。');
redirect('books');
}
}
// ---- GET:先"顺手同步一次"(用户第 8 条:定期自动同步)----
//
// ⚠️ 位置很要紧:放在**渲染之前**,这样这一页看到的书架就是最新的;
// 而 maybeSync 内部按间隔判定,绝大多数请求只是读一个时间戳(不联网)。
$syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
$syncRes = WeRead::maybeSync((int)Auth::familyId(), $me, false);
}
View::render('books/weread', [
'title' => '微信读书导入',
'preview' => $imported,
'nbCount' => 0,
'sync' => $syncRes,
'stats' => WeRead::readStats($me, 'overall'),
], 'books');
break;