v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式

其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
This commit is contained in:
2026-10-08 02:20:19 +08:00
parent 404b1afee0
commit 6852b4ee2c
19 changed files with 2273 additions and 587 deletions
+190 -10
View File
@@ -35,6 +35,7 @@ use App\Ics;
use App\Kinship;
use App\Ledger;
use App\Lunar;
use App\Markdown;
use App\Movie;
use App\MovieReview;
use App\Module;
@@ -50,6 +51,7 @@ use App\PersonJob;
use App\PersonNote;
use App\PersonQuery;
use App\PersonRoster;
use App\PersonTag;
use App\Pinyin;
use App\Relations;
use App\Repeat;
@@ -917,12 +919,26 @@ switch ($r) {
}
unset($row);
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序);
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。
$grouped = [];
foreach ($rows as $row) {
$grouped[(int)($row['group_id'] ?? 0)][] = $row;
// 🔴🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
// 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
// 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
// ⇒ 不再**按分组分栏**(`$grouped`),改成:
// · 名册**平铺**成一张列表(带拖动排序,用户明确要求保留);
// · 顶部一条**标签筛选**(`?tag=xxx`),点哪个看哪个;
// · 每张卡片上挂着他自己的那几个标签(一个人可以有好几个)。
// ⚠️ 仍然传 `groups`:这就是"标签的候选来源"(用户可以在卡片上选已有的标签),
// 它本身没废 —— 只是从"分栏依据"降级成"可选的标签词表"。
$tagFilter = trim((string)($_GET['tag'] ?? ''));
$tagsOf = PersonTag::mapFor($familyId, $me);
// 按标签筛选:留在这个标签里的人(`?tag=` 为空 = 全部)
if ($tagFilter !== '') {
$rows = array_values(array_filter($rows,
static fn(array $r): bool => in_array($tagFilter, $tagsOf[(int)$r['id']] ?? [], true)));
}
foreach ($rows as &$row) {
$row['tags'] = $tagsOf[(int)$row['id']] ?? [];
}
unset($row);
// v24 第 9 批 #5:千人名册的 HTML 有 3~4 MB。本机 php -S 与部分虚拟机
// 都不会替 PHP 输出开 gzip,原样吐 3.8 MB 又慢又费流量;
@@ -936,8 +952,12 @@ switch ($r) {
View::render('people/index', [
'title' => '家庭名册',
'people' => $rows,
'grouped'=> $grouped,
// v28:`$grouped`(按分栏分堆)已不再需要 —— 名册平铺(见上面注释)。
// 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。
'groups' => PersonGroup::orderFor($familyId, $me),
// v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选)
'tagCloud' => PersonTag::cloud($familyId, $me),
'tagFilter' => $tagFilter,
'q' => $q,
'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
@@ -1022,12 +1042,81 @@ switch ($r) {
break;
/**
* 名册拖动排序 / 拖动换栏 —— 整版提交。
* 名册卡片上的「标签」(v28 用户第 5 条:分组 → 标签)。
*
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
* 与 people/group 同一套做法(AJAX 回 JSON 就地更新 / 普通提交 302 兜底),
* 只是把"选一个分组"换成"整组替换他的标签"。
*
* ⚠️ 标签是**每人一份**的(owner_id = 我),改它只影响我自己的名册 ——
* 我给他打「球友」不该让全家的名册都多出这个词。
*/
case 'people/tags':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$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, 'msg' => '你的身份是访客,不能改名册标签。'],
JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '你的身份是访客,不能改名册标签。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_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 (!isset(PersonRoster::visibleIds($familyId, $me)[$pid])) {
$msg = '这个人不在你的名册里。';
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 200);
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('people');
}
$saved = PersonTag::setFor($familyId, $me, $pid, (string)($_POST['tags'] ?? ''));
$msg = $saved
? '标签已改成:' . implode('、', array_map(static fn($t): string => '#' . $t, $saved)) . '。'
: '标签已清空。';
if ($isAjax) {
// ⚠️ 前端要用这个数组去改卡片上那颗按钮的文字(空数组 = 显示「标签」)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'pid' => $pid,
'tags' => $saved,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('people');
break;
/**
* 名册拖动排序 —— 整版提交。
*
* 🔴 v28:名册已**平铺**(用户第 5 条:分组→标签),所以这里只剩"排序"这一件事,
* 不再有"换栏"。前端发上来的是一个不含 `g` 的栏(见 app.js 的 save),
* 服务端 `applyLayout` 据此只更新 sort_order、不碰 group_id。
*
* 前端把有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/
case 'people/reorder':
Auth::requireLogin();
@@ -3694,7 +3783,33 @@ switch ($r) {
echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE);
exit;
}
$want = array_values(array_filter(array_map('strval', (array)($_GET['kinds'] ?? []))));
/* 🔴🔴 v28 用户第 8 条:「财务,多选分类的时候,净资产 · 金额变化这个图表,
里面的数字还是不改变。」
根因:**前后端的参数形状对不上** ——
· 前端(templates/ledger/index.php)发的是 `&kinds=deposit%2Chouse`
(把选中集合拼成**一个逗号串**,因为 URLSearchParams 里一个 key 只能一个值);
· 服务端这里写的是 `(array)($_GET['kinds'] ?? [])` —— 对字符串来说是
`['deposit,house']`(**一个元素**,值是带逗号的整串),
拿去和真实类别白名单求交 → 空 → 下面的 `if (!$kinds)` 分支把它当成"空集合"
→ **回落成"整本账"** → 图与数字当然不变。
⇒ 修法:把逗号串**也切开**(两种形状都收:`kinds=a,b` 与 `kinds[]=a&kinds[]=b`)。
只收一种形状就会在下次换前端写法时再断一次 —— 收两种是一行代码的事。
⚠️ 这与 `Ledger::timeline()` 的入参口径一致:它本来就同时接受字符串与数组。 */
$_rawKinds = $_GET['kinds'] ?? [];
if (!is_array($_rawKinds)) {
$_rawKinds = [$_rawKinds];
}
$want = [];
foreach ($_rawKinds as $_rk) {
// 每个值再按逗号切一次 —— `a,b` 与 `a` 都能吃
foreach (preg_split('/[,\s]+/', (string)$_rk) ?: [] as $_one) {
$_one = trim($_one);
if ($_one !== '') {
$want[$_one] = true;
}
}
}
$want = array_keys($want);
// 白名单:只留下这本账里真实存在的类别
$present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind']));
$kinds = array_values(array_intersect($want, $present));
@@ -6324,6 +6439,71 @@ switch ($r) {
redirect('notes');
break;
/* 笔记底部附件(v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面」)。
⚠️ 只允许给**我自己的**笔记加附件:附件是挂在笔记上的东西,
共享出去的笔记别人只能看,不该能往里塞文件。
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */
case 'notes/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$n0 = Note::find($familyId, $id, $me);
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这条笔记不能改。');
redirect('notes');
}
$warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me);
flash($warns ? 'err' : 'ok',
$warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('notes/form', ['id' => $id]);
break;
case 'notes/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 先按附件 id 找到它挂在**哪条笔记**上,再判那条笔记是不是我的 ——
// 只信传进来的附件 id,等于谁都能构造请求删别人家的附件(资源页踩过)。
$st = Database::get()->prepare(
'SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'
);
$st->execute([$aid, Note::ATTACH]);
$nid = (int)$st->fetchColumn();
$n0 = $nid > 0 ? Note::find($familyId, $nid, $me) : null;
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这个附件不能删。');
redirect('notes');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('notes/form', ['id' => $nid]);
break;
/* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。
⚠️ 为什么预览走**服务端**而不是在前端再写一份渲染器:
两套渲染器必然漂移(前端显示成粗体、保存后却不是),而用户看到的
就是"预览",一旦不一致,他会以为保存坏了。
服务端只有一份 `App\Markdown`,预览与最终渲染保证同源。
⚠️ 所以这段必须够轻:只做一段文本的行内+块级渲染,不碰数据库、不查权限
(不涉及任何数据,纯函数),前端再配合防抖调用。
⚠️ 它仍然要求登录 + 写权限:不是安全所需(没有数据泄露),而是不给
"未登录也能拿这个当 Markdown 渲染器用"的口子。 */
case 'notes/preview':
Auth::requireWrite();
csrf_check();
$src = (string)($_POST['md'] ?? '');
// 限长:预览是交互用的,不该被拿来转整本书
$src = mb_substr($src, 0, Note::MAX_TEXT, 'UTF-8');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'html' => Markdown::render($src)],
JSON_UNESCAPED_UNICODE);
break;
/* 图片块上传:走统一附件表(target_type='note')。
⚠️ 为什么复用 attachments 而不在 notes 里另起一套:
图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份,