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
+106 -5
View File
@@ -40,14 +40,20 @@ namespace App;
final class Note
{
/** 附件(图片块)在统一 attachments 表里的宿主类型 */
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
public const ATTACH = 'note';
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
public const BLOCK_KINDS = ['text', 'image', 'list'];
/** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */
public const MAX_BLOCKS = 60;
/**
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
*
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
*/
public const MAX_BLOCKS = 200;
public const MAX_TEXT = 20000;
/** 列表页默认显示多少条(超过给"加载更多") */
@@ -156,6 +162,10 @@ final class Note
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
);
foreach ($st->fetchAll() as $b) {
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
$b['html'] = (string)$b['kind'] === 'text'
? Markdown::render((string)$b['content'])
: '';
$blocks[(int)$b['note_id']][] = $b;
}
@@ -171,11 +181,47 @@ final class Note
$id = (int)$r['id'];
$r['blocks'] = $blocks[$id] ?? [];
$r['tags'] = $tags[$id] ?? [];
/* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。
摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML,
这样列表页再长也不会因为 Markdown 变慢。 */
$r['excerpt'] = self::excerptOf($r);
}
unset($r);
return $rows;
}
/**
* 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。
* 图片块计作 [图片],清单块取前几行,段落走 Markdown 去记号。
*/
private static function excerptOf(array $note, int $len = 110): string
{
$buf = [];
foreach ((array)($note['blocks'] ?? []) as $b) {
$kind = (string)$b['kind'];
$c = (string)$b['content'];
if ($kind === 'image') {
$buf[] = '[图片]';
} elseif ($kind === 'list') {
$buf[] = Markdown::excerpt($c, 60);
} else {
$buf[] = Markdown::excerpt($c, 200);
}
// 凑够长度就不再往下拼(长笔记没必要全量处理一遍)
if (mb_strlen(implode(' ', $buf), 'UTF-8') >= $len) {
break;
}
}
$txt = trim((string)preg_replace('/\s+/u', ' ', implode(' ', $buf)));
if ($txt === '') {
return '';
}
if (mb_strlen($txt, 'UTF-8') > $len) {
$txt = mb_substr($txt, 0, $len, 'UTF-8') . '…';
}
return $txt;
}
/** 单条(带权限判定)。看不到返回 null。 */
public static function find(int $familyId, int $id, int $viewer): ?array
{
@@ -191,18 +237,73 @@ final class Note
}
$r['blocks'] = self::blocks($id);
$r['tags'] = self::tags($id);
$r['files'] = self::files($id); // v28:底部附件
$r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
return $r;
}
/** 某条笔记的块(按位置) */
/**
* 一条笔记的**底部附件**(v28 第 12 条:「再加上附件上传功能,
* 附件,都放在笔记最下面就行了。」)。
*
* ⚠️ 与**图片块**(kind=image)不是一回事,别混:
* · 图片块 = 插在正文中间、参与图文混排的图(用户第 12 条要的"图片和正文混排");
* · 底部附件 = 挂在笔记末尾的任意文件(PDF / 压缩包 / 表格…)。
* 两者共用 `attachments` 表(target_type='note'),靠这个顺序体现区别:
* 图片块在上传时就由 `claimUploads` 认领(target_id=真实笔记 id),
* 底部附件则由 `attach_add` 路由显式挂上来 —— **都是同一张表的行**,
* 所以删笔记时外键级联一把全部带走,不会留孤儿。
*
* 怎么区分两者(同一个 target_id 下):图片块的那些**已经作为 image 块的内容**
* 出现在 note_blocks 里;渲染底部附件时把它们排掉即可。
*
* @return array<int,array> attachments 行(按 position)
*/
public static function files(int $noteId): array
{
if ($noteId <= 0) {
return [];
}
// 先取出"用作图片块的那些 key",避免它们在底部附件里重复出现
$used = [];
foreach (self::blocks($noteId) as $b) {
if ((string)$b['kind'] === 'image') {
$used[(string)$b['content']] = true;
}
}
$out = [];
foreach (Attachment::list(self::ATTACH, $noteId) as $a) {
if (isset($used[(string)$a['key']])) {
continue;
}
$out[] = $a;
}
return $out;
}
/**
* 某条笔记的块(按位置)
*
* ⚠️ 这里给每个 text 块算好 `html`(Markdown 渲染结果)——**在模型层算一次**,
* 列表页与详情页共用,不必各自再调一遍 Markdown::render
* (两处各算一次,迟早有一处忘了转义或忘了渲染)。
*/
public static function blocks(int $noteId): array
{
$st = Database::get()->prepare(
'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id'
);
$st->execute([$noteId]);
return $st->fetchAll();
$rows = $st->fetchAll();
foreach ($rows as &$r) {
$kind = (string)$r['kind'];
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
$r['html'] = $kind === 'text'
? Markdown::render((string)$r['content'])
: '';
}
unset($r);
return $rows;
}
/** 某条笔记的标签 */