From 37ba86321bfd6a3e6f5652974326fd94e0619885 Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 8 Oct 2026 10:54:43 +0800 Subject: [PATCH] =?UTF-8?q?v29=20=E7=AC=AC=2029=20=E6=89=B9=EF=BC=889=20?= =?UTF-8?q?=E6=9D=A1=EF=BC=89=EF=BC=9A=E6=97=A5=E7=A8=8B=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?/=E5=A4=8D=E9=80=89=E6=A1=86=E6=8F=90=E7=A4=BA/=E5=90=8D?= =?UTF-8?q?=E5=86=8C=E9=94=99=E4=B9=B1=E4=B8=89=E5=A4=84=E7=9C=9F=E5=9B=A0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E8=AF=BB=E4=B9=A6=E8=90=BD=E7=9B=98?= =?UTF-8?q?=20+=20=E5=AF=BC=E8=88=AA=E5=9B=9B=E8=AE=BE=E7=BD=AE=20+=20?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E9=98=85=E8=AF=BB=E6=80=81=E4=B8=8E=E5=85=B1?= =?UTF-8?q?=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下: 1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是 `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染** (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。 有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。 2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字 `` 用的类名**在 app.css 里从来没有定义过** (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。 已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。 3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 ``**(闭合 .person), 浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。 `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。 另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。 4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」: 新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON); 渲染路径改走 notebooksCached()/readStatsCached()(永不联网); 同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。 5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine), 表单带 back 域 → 保存后回原来那一项。 🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾, 把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。 6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。 7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块), 两者共用同一份命令表。 8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改); 清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。 9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构); 可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。 --- app/Note.php | 265 +++++++++++++++++--- app/Schema.php | 111 ++++++++- app/WeRead.php | 158 +++++++++++- app/WeReadCache.php | 138 +++++++++++ assets/css/app.css | 139 +++++++++-- assets/js/note_editor.js | 406 ++++++++++++++++++++++++------ index.php | 176 ++++++++++--- templates/home.php | 19 +- templates/nav/index.php | 121 +++++++-- templates/notes/form.php | 489 ++++++++++++++++++++++++------------- templates/notes/index.php | 25 +- templates/people/index.php | 13 +- 12 files changed, 1714 insertions(+), 346 deletions(-) create mode 100644 app/WeReadCache.php diff --git a/app/Note.php b/app/Note.php index 65bf40e..f840f59 100644 --- a/app/Note.php +++ b/app/Note.php @@ -43,8 +43,15 @@ final class Note /** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */ public const ATTACH = 'note'; - /** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */ - public const BLOCK_KINDS = ['text', 'image', 'list']; + /** 块类型白名单(写在服务端;前端传别的值一律归一成 text) + * + * 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能, + * 把段落做的完善一点就可以了。」 + * ⇒ 收成 `text` / `image` 两种。 + * ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉, + * 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。 + * 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */ + public const BLOCK_KINDS = ['text', 'image']; /** * 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) @@ -59,6 +66,22 @@ final class Note /** 列表页默认显示多少条(超过给"加载更多") */ public const LIST_LIMIT = 30; + /** + * **可见性的唯一判据**(v29 第 9 条)。 + * + * 「这条笔记是我写的」**或**「有一条指向我的共享记录」。 + * ⚠️ 全站(列表 / 计数 / 详情 / 标签云)都拼这**同一个**串 —— + * 各写各的迟早出现"列表里看得到、点进去 404"这种鬼故事。 + * ⚠️ 绑定参数顺序:调用方在它**前面**绑 `$viewer`(对应 n.person_id)。 + * ⚠️ 用 EXISTS 而不是 JOIN:共享给多个人时 JOIN 会让同一条笔记出现多次。 + */ + public const VIS_COND = + '(n.person_id = ? OR EXISTS (SELECT 1 FROM note_shares s + WHERE s.note_id = n.id AND s.person_id = ?))'; + + /** 共享权限的白名单(与 resources 同一套语义) */ + public const PERMS = ['read', 'write']; + // ===================================================================== // 读 // ===================================================================== @@ -81,12 +104,22 @@ final class Note int $limit = 0, int $offset = 0 ): array { + /* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**: + 「我写的」**或**「有一条指向我的共享记录」。 + ⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)—— + v27 那个 `is_shared = 1`(共享给全家)已不再是判据, + 升级时它被展开成逐人记录(见 Schema 的迁移)。 + ⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的 + 笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */ + $vis = self::VIS_COND; $sql = 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color, CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine FROM notes n JOIN persons p ON p.id = n.person_id - WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; - $bind = [$viewer, $familyId, $viewer]; + WHERE n.family_id = ? AND ' . $vis; + // ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次), + // 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。 + $bind = [$viewer, $familyId, $viewer, $viewer]; $kw = trim($keyword); if ($kw !== '') { @@ -123,8 +156,8 @@ final class Note public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int { $sql = 'SELECT COUNT(*) FROM notes n - WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; - $bind = [$familyId, $viewer]; + WHERE n.family_id = ? AND ' . self::VIS_COND; + $bind = [$familyId, $viewer, $viewer]; $kw = trim($keyword); if ($kw !== '') { $sql .= ' AND (n.title LIKE ? @@ -162,10 +195,7 @@ 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']) - : ''; + $b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']); $blocks[(int)$b['note_id']][] = $b; } @@ -228,9 +258,9 @@ final class Note $st = Database::get()->prepare( 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color FROM notes n JOIN persons p ON p.id = n.person_id - WHERE n.id = ? AND n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)' - ); - $st->execute([$id, $familyId, $viewer]); + WHERE n.id = ? AND n.family_id = ? AND ' . self::VIS_COND); + // ⚠️ VIS_COND 两个 `?`,$viewer 绑两遍(顺序:id / family / person_id / person_id) + $st->execute([$id, $familyId, $viewer, $viewer]); $r = $st->fetch(); if (!$r) { return null; @@ -296,11 +326,7 @@ final class Note $st->execute([$noteId]); $rows = $st->fetchAll(); foreach ($rows as &$r) { - $kind = (string)$r['kind']; - // 只有段落支持 Markdown(清单走自己的渲染、图片走 media) - $r['html'] = $kind === 'text' - ? Markdown::render((string)$r['content']) - : ''; + $r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']); } unset($r); return $rows; @@ -329,10 +355,10 @@ final class Note $st = Database::get()->prepare( 'SELECT t.tag, COUNT(DISTINCT n.id) AS n FROM note_tags t JOIN notes n ON n.id = t.note_id - WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1) + WHERE n.family_id = ? AND ' . self::VIS_COND . ' GROUP BY t.tag ORDER BY n DESC, t.tag ASC' ); - $st->execute([$familyId, $viewer]); + $st->execute([$familyId, $viewer, $viewer]); return $st->fetchAll(); } @@ -369,13 +395,15 @@ final class Note return 0; } $db = Database::get(); + // ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)—— + // 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互), + // 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。 $db->prepare( 'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at) - VALUES (?,?,?,?,NOW(),NOW())' + VALUES (?,?,?,0,NOW(),NOW())' )->execute([ $familyId, $personId, mb_substr($title, 0, 200, 'UTF-8'), - !empty($d['is_shared']) ? 1 : 0, ]); $id = (int)$db->lastInsertId(); self::writeBlocks($id, $blocks); @@ -386,15 +414,22 @@ final class Note } /** - * 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。 - * @return bool 是否改到了(改不到 = 不是我的/不存在) + * 改一条笔记。 + * + * 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是 + * 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。 + * v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个 + * 开关时的简化;现在有了逐人权限,就该按权限来。 + * ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的, + * "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。 + * @return bool 是否改到了(改不到 = 看不到 / 没有写权限) */ public static function update(int $familyId, int $id, int $personId, array $d): bool { $db = Database::get(); - $st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?'); - $st->execute([$id, $familyId, $personId]); - if (!$st->fetchColumn()) { + // 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改 + $note = self::find($familyId, $id, $personId); + if (!$note || !self::canWrite($note, $personId)) { return false; } $title = trim((string)($d['title'] ?? '')); @@ -404,9 +439,11 @@ final class Note if ($title === '' && !$blocks) { $title = '(空笔记)'; } - $db->prepare( - 'UPDATE notes SET title = ?, is_shared = ?, updated_at = NOW() WHERE id = ?' - )->execute([mb_substr($title, 0, 200, 'UTF-8'), !empty($d['is_shared']) ? 1 : 0, $id]); + // ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。 + // 老列留着不动(升级迁移时读过它一次),但**不再由这里改** —— + // 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。 + $db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?') + ->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]); // 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值, // 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。 self::writeBlocks($id, $blocks); @@ -455,6 +492,95 @@ final class Note )->execute([$familyId]); } + // ===================================================================== + // 共享(v29 用户第 9 条)—— 与 App\Resource 同一套语义 + // ===================================================================== + + /** 这条笔记共享给了谁(带权限与对方的名字/头像) */ + public static function shares(int $noteId): array + { + $st = Database::get()->prepare( + 'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color + FROM note_shares s JOIN persons p ON p.id = s.person_id + WHERE s.note_id = ? ORDER BY p.id' + ); + $st->execute([$noteId]); + return $st->fetchAll(); + } + + /** 一条笔记共享给了几个人的映射(列表页一次性取,避免每条一次查询) */ + public static function shareCounts(array $noteIds): array + { + $ids = array_values(array_filter(array_map('intval', $noteIds))); + if (!$ids) { + return []; + } + $in = implode(',', $ids); + $out = []; + foreach (Database::get()->query( + "SELECT note_id, COUNT(*) AS n FROM note_shares + WHERE note_id IN ($in) GROUP BY note_id" + )->fetchAll() as $r) { + $out[(int)$r['note_id']] = (int)$r['n']; + } + return $out; + } + + /** + * 设共享。 + * + * ⚠️ 作者本人**不进**共享表 —— 他本来就全能, + * 写进去只会让"共享给谁"的列表里冒出自己(同 Resource::setShare)。 + */ + public static function setShare(int $noteId, int $ownerId, int $personId, string $perm): void + { + if ($personId <= 0 || $personId === $ownerId) { + self::dropShare($noteId, $personId); + return; + } + Database::get()->prepare( + 'INSERT INTO note_shares (note_id, person_id, perm) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE perm = VALUES(perm)' + )->execute([$noteId, $personId, in_array($perm, self::PERMS, true) ? $perm : 'read']); + } + + public static function dropShare(int $noteId, int $personId): void + { + Database::get()->prepare('DELETE FROM note_shares WHERE note_id = ? AND person_id = ?') + ->execute([$noteId, $personId]); + } + + /** + * 我对某条笔记的权限:'owner' / 'write' / 'read' / ''(看不到)。 + * + * ⚠️ 这是**唯一**的权限判定(页面按钮、服务端写入校验都读它)—— + * 两处各写一套的话,迟早出现"按钮没显示但接口能改"(或者反过来)。 + */ + public static function permOf(array $note, int $viewer): string + { + if ((int)$note['person_id'] === $viewer) { + return 'owner'; + } + $st = Database::get()->prepare( + 'SELECT perm FROM note_shares WHERE note_id = ? AND person_id = ?' + ); + $st->execute([(int)$note['id'], $viewer]); + $p = (string)($st->fetchColumn() ?: ''); + return in_array($p, self::PERMS, true) ? $p : ''; + } + + /** 能改吗(作者或 write 权限)。删除**只限作者** —— 见 destroy()。 */ + public static function canWrite(array $note, int $viewer): bool + { + $p = self::permOf($note, $viewer); + return $p === 'owner' || $p === 'write'; + } + + public static function permWord(string $perm): string + { + return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读'); + } + /** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */ public static function destroy(int $familyId, int $id, int $personId): void { @@ -498,13 +624,83 @@ final class Note // 内部:块 / 标签的清洗与写入 // ===================================================================== + /** + * 一个块的渲染结果(HTML)。**全站唯一**的块渲染入口。 + * + * 🔴 v29 第 8 条:清单块并入 Markdown 之后,这里要**同时认老数据**: + * 库里可能还躺着 `kind = 'list'` 的块(v27/v28 写的), + * 用户没编辑过它们 → 仍是 list → 必须照样渲染出列表,不能变成空白。 + * ⚠️ 走同一套 `listToMarkdown` 转换(与 cleanBlocks 共用), + * 这样"老块在页面上"与"老库保存一次之后的文本"长得完全一样。 + * + * @return string 图片块返回 ''(图片不用 HTML,由模板直接出 ) + */ + public static function blockHtml(string $kind, string $content): string + { + if ($kind === 'image') { + return ''; + } + if ($kind === 'list') { + // 历史清单块 → 转成 Markdown 列表再渲染(数据不丢,样子不变) + return Markdown::render(self::listToMarkdown($content)); + } + return Markdown::render($content); + } + + /** 历史清单块 → Markdown 文本的**对外**入口(模板把老块放进编辑器时用)。 + * + * ⚠️ 为什么要暴露它:编辑态要把"库里的块"变成"编辑器里的文本", + * 而 list 块的转换逻辑只该有一份(`listToMarkdown`)。 + * 模板直接调它,比在模板里再写一遍正则可靠得多。 + * @param string $content 清单块的原始内容(每行一项,缩进表层级) + */ + public static function blockHtmlOut(string $content): string + { + return self::listToMarkdown($content); + } + + /** + * 历史清单块 → Markdown 无序列表文本(v29 收掉清单块时用)。 + * + * 约定(v27 定下的):每行一项,**行首缩进表示层级**。 + * 这里按缩进深度换算成 Markdown 的缩进(同样表层级),最多 3 层 —— + * 更深的层级在 20px 的行高里已经看不出来了,硬转反而让源码难读。 + */ + private static function listToMarkdown(string $content): string + { + $lines = []; + foreach (preg_split('/\R/u', $content) ?: [] as $line) { + $raw = rtrim($line, " \t"); + if (trim($raw) === '') { + continue; + } + $indent = strlen($raw) - strlen(ltrim($raw, " \t")); + $depth = min(3, (int)floor($indent / 2)); + $lines[] = str_repeat(' ', $depth) . '- ' . trim($raw); + } + return implode("\n", $lines); + } + /** * 清洗前端提交的块: - * · kind 归一(只认白名单里的三种,其它一律当 text) + * · kind 归一(只认白名单里的两种,其它一律当 text) + * · **历史 list 块转成 Markdown 列表文本**(见下面的说明) * · 内容裁剪(去首尾空白;图片块 content = 附件 key) * · 丢掉**空块**(用户加了块又没填,不该留一堆空段) * · 上限 MAX_BLOCKS * + * 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能, + * 把段落做的完善一点就可以了。」 + * ⇒ 块类型收成 `text` / `image` 两种。 + * ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。 + * 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失** + * (最糟的一种数据丢失:用户什么都没做,内容自己没了)。 + * 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `), + * 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表), + * 而用户以后可以在它上面用 Markdown 继续写。 + * ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按 + * 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。 + * * @return array */ private static function cleanBlocks($raw): array @@ -522,6 +718,13 @@ final class Note $kind = 'text'; } $content = (string)($b['content'] ?? ''); + + // ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)---- + if ($kind === 'list') { + $content = self::listToMarkdown($content); + $kind = 'text'; + } + // 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查) $content = $kind === 'image' ? trim(preg_replace('/\s+/', '', $content) ?? '') diff --git a/app/Schema.php b/app/Schema.php index b936455..e55e1fa 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -108,6 +108,22 @@ final class Schema * ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点), * 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。 * 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。 + * 29 → **微信读书数据落盘(用户 2026-10-08 第 4 条)**:新增 `weread_snapshots` + * (按 (人, 数据集) 存 JSON 快照)。动机是用户的原话: + * 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取 + * 微信读书的数据了。」 + * ⇒ `notebooks()` / `readStats()` 在**页面渲染路径**上不再打网关, + * 只读快照;打接口只发生在同步时(手动 / 6 小时到期)。 + * ⚠️ 快照有 `updated_at`,过期(默认 24 小时)仍会触发一次后台同步, + * 但**渲染永远不等它** —— 拿旧快照先渲染,同步结果下次访问可见。 + * 30 → **笔记共享改成"逐人 + 权限"(用户 2026-10-08 第 9 条)**:新增 + * `note_shares`(与 `resource_shares` 同构)。动机是用户的原话: + * 「笔记的共享,和其他的模块一样,选择共享人,选择是只读还是可以读写。和资源类似。」 + * ⇒ 取消 v27 的"一个 `is_shared` 布尔(共享给全家)", + * 老数据升级时**展开成逐人记录**(给家里每个人一条 read)。 + * ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次: + * 迁移是"一次性数据转换",不是"持续同步", + * 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -115,7 +131,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '28'; + public const VERSION = '30'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -1008,6 +1024,62 @@ CREATE TABLE IF NOT EXISTS `person_tags` ( CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")' +SQL, + <<<'SQL' +/* 🔴 v29 用户第 4 条:**微信读书的数据落盘,页面只读本地**。 + 用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都 + 读取微信读书的数据了。」 + ⇒ 症结确实在这:`notebooks()` 与 `readStats()` 每次渲染都**实时打网关**, + 一次读书页要打好几次外部请求(且官方接口本身就慢)。 + 实测口径:以前每个请求都要等外部接口返回,`SYNC_BUDGET` 只能限制"同步"那一段, + 限不住"每次渲染都去读统计"那一段。 + ⇒ 做法:**一份按 (人, 数据集) 存的 JSON 快照**。 + · 页面渲染只读这张表(本地查询,毫秒级); + · 真正打接口只发生在**同步**时(手动「立即同步」或 6 小时到期的自动同步); + · 同步成功后把相关快照一起刷新。 + ⚠️ 为什么用"一个 JSON 列"而不是把统计拆成多张表: + 这些数据是**只读的远端结果**、字段完全由官方决定(说改就改), + 本站从不按其中任何字段做查询。拆表=把别人的字段设计背到自己身上, + 官方一变就要迁移。存 JSON = 官方怎么变都能原样兜住。 + ⚠️ 为什么不像封面那样塞进 `book`/`attachment`:它不是"我们的实体", + 是一次外部调用的缓存,有明确的**时效性**(`updated_at`), + 过期就该重新拉 —— 与"实体数据"的生命周期完全不同,混在一起会后患无穷。 */ +CREATE TABLE IF NOT EXISTS `weread_snapshots` ( + `person_id` INT UNSIGNED NOT NULL, + `k` VARCHAR(40) NOT NULL COMMENT '数据集名:notebooks / stats_overall / stats_weekly …', + `v` LONGTEXT DEFAULT NULL COMMENT '原样存下来的 JSON(官方字段,本站不解析、不索引)', + `updated_at` DATETIME NOT NULL COMMENT '这次快照是什么时候拉的(判过期用)', + PRIMARY KEY (`person_id`, `k`), + KEY `idx_ws_updated` (`updated_at`), + CONSTRAINT `fk_ws_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='微信读书的远端数据快照(v29:页面只读本地,不再每次打网关)' +SQL, + <<<'SQL' +/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人, + 选择是只读还是可以读写。和资源类似。」 + ⇒ 把 v27 那个"一个 is_shared 布尔(共享给全家)"升级成**逐人共享 + 权限**。 + ⚠️ 为什么不是"保留 is_shared,再加一张人表": + 那样会出现两种共享语义并存("全家可见" + "给这几个人"), + 判定优先级、列表怎么算、谁能看到 —— 全是边界问题。 + 统一成一种(逐人 + 权限)之后,判定只有一处: + `owner 本人 or 在某条 share 记录里`。 + ⚠️ `notes.is_shared` 列**保留但不再作为判据**(升级时把老的"1"展开成 + "给全家每个人一条 read 记录",迁移完就没它的事了)。 + 保留列是为了老库升级时能读到它(见 migrateSharedNotes)。 + ⚠️ 与资源共享表**同构**(列名/枚举都一样):两处的权限语义要一致, + 表结构一样,读代码的人一眼就知道"跟资源那套是一回事"。 */ +CREATE TABLE IF NOT EXISTS `note_shares` ( + `note_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(**不含**作者 —— 自己写的本来就能改)', + `perm` ENUM('read','write') NOT NULL DEFAULT 'read' COMMENT 'read=只读 / write=可读写', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`note_id`, `person_id`), + KEY `idx_ns_person` (`person_id`), + CONSTRAINT `fk_ns_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)' SQL, ]; } @@ -1894,6 +1966,43 @@ SQL); } }); + /* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人, + 选择是只读还是可以读写。和资源类似。」 + ⇒ 把 v27 的"`is_shared = 1`(共享给全家)"展开成**逐人记录**。 + 怎么做:对每一条 `is_shared = 1` 的笔记,给**家里其他每个人**写一条 + `perm = 'read'`(读得到,但不能改 —— 原来的语义就是"全家可见", + 并没有给过他们改的权限,迁移必须**保持原语义**,不能顺手扩大权限)。 + ⚠️ 排除作者自己:作者本来就全能,给自己写一条共享记录会污染"共享给谁"的列表。 + ⚠️ 一次性哨兵(`note_shares_migrated_v29`)—— 理由与 v28 那次完全一样: + 迁移是"一次性数据转换",不是"持续同步"。否则用户把某人从共享里去掉后, + 下次启动又被灌回来(本项目已栽过 4 次,见 v28 的详细记录)。 + ⚠️ 这个迁移**必须在 note_shares 建好之后**跑(外键指向 notes/persons, + 两张表都在 statements() 里先建出来了)。 */ + self::tryStep('笔记共享展开成逐人记录(全家可见 → 逐人只读)', static function () use ($db) { + $flag = 'note_shares_migrated_v29'; + $has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn(); + if ((int)$has > 0) { + return; + } + if (self::hasTable($db, 'note_shares')) { + // 只处理"曾经标过共享"的笔记;逐人展开,权限一律 read(保持原语义) + $db->exec( + "INSERT IGNORE INTO `note_shares` (note_id, person_id, perm) + SELECT n.id, p.id, 'read' + FROM `notes` n + JOIN `persons` p ON p.family_id = n.family_id + WHERE n.is_shared = 1 + AND p.id <> n.person_id + AND p.kind = 'member'" + ); + } + $fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn(); + if ($fid > 0) { + $db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)") + ->execute([$fid, $flag, date('Y-m-d H:i:s')]); + } + }); + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { diff --git a/app/WeRead.php b/app/WeRead.php index 532b9a4..5ddc685 100644 --- a/app/WeRead.php +++ b/app/WeRead.php @@ -91,6 +91,22 @@ final class WeRead /** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */ public const SYNC_MAX_BOOKS = 25; + /** + * 快照的**保鲜期**(秒)—— 默认 24 小时。 + * + * 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘, + * 这样就不用每次都读取微信读书的数据了。」 + * + * ⇒ 落盘之后,"多久算旧"就成了新问题: + * · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘; + * · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。 + * 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的), + * 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。 + * ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。 + * 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。 + */ + public const SNAP_TTL = 86400; + // ================================================================ // Key 的存取(加密) // ================================================================ @@ -568,7 +584,8 @@ final class WeRead return !empty($up['ok']) ? (string)$up['file'] : ''; } - /** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */ + /** + * 标题 → 可做文件名的安全基名(去掉路径符号,限长) */ private static function safeBase(string $s): string { $s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? ''; @@ -576,6 +593,128 @@ final class WeRead return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8'); } + // ================================================================ + // v29 用户第 4 条:**本地快照优先**的取数入口 + // ================================================================ + // + // 规矩(只此一条,别处照做): + // · **渲染路径**一律调 `*Cached()`,它永不联网; + // · **同步路径**(手动「立即同步」/ 6 小时到期)才调真正的 `notebooks()`/`readStats()`, + // 拿到结果后用 `snapNotebooks()` / `snapStats()` 落盘。 + // + // ⚠️ 缓存里**没有**(第一次用、刚换 Key)时怎么办: + // 这时页面没有任何数据可显示,只能真去打一次 —— 但那是"首次",不是常态。 + // 之后的每一次访问都命中本地,这就是用户要的"落盘,不用每次去读"。 + + /** + * 有笔记的书清单(**优先读本地快照**)。 + * + * @param bool $allowFetch 本地没有时,是否允许真的去联网取(默认允许) + * @return array{ok:bool,books:array,error:string,cached:bool,updated_at:int} + */ + public static function notebooksCached(int $personId, bool $allowFetch = true): array + { + $snap = WeReadCache::get($personId, WeReadCache::K_NOTEBOOKS); + if ($snap['ok'] && is_array($snap['data'])) { + return ['ok' => true, 'books' => (array)($snap['data']['books'] ?? []), + 'error' => '', 'cached' => true, 'updated_at' => (int)$snap['updated_at']]; + } + if (!$allowFetch) { + return ['ok' => true, 'books' => [], 'error' => '', 'cached' => true, 'updated_at' => 0]; + } + $r = self::notebooks($personId); + if ($r['ok']) { + self::snapNotebooks($personId, (array)$r['books']); + } + return ['ok' => (bool)$r['ok'], 'books' => (array)($r['books'] ?? []), + 'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()]; + } + + /** 把书清单写进快照 */ + public static function snapNotebooks(int $personId, array $books): void + { + WeReadCache::put($personId, WeReadCache::K_NOTEBOOKS, ['books' => $books]); + } + + /** 阅读统计(**优先读本地快照**,按 mode 各存一份) */ + public static function readStatsCached(int $personId, string $mode = 'overall', + bool $allowFetch = true): array + { + if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) { + $mode = 'overall'; + } + $key = WeReadCache::statsKey($mode); + $snap = WeReadCache::get($personId, $key); + if ($snap['ok'] && is_array($snap['data'])) { + return ['ok' => true, 'data' => (array)$snap['data'], 'error' => '', + 'cached' => true, 'updated_at' => (int)$snap['updated_at']]; + } + if (!$allowFetch) { + return ['ok' => true, 'data' => [], 'error' => '', 'cached' => true, 'updated_at' => 0]; + } + $r = self::readStats($personId, $mode); + if ($r['ok']) { + self::snapStats($personId, $mode, (array)$r['data']); + } + return ['ok' => (bool)$r['ok'], 'data' => (array)($r['data'] ?? []), + 'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()]; + } + + /** 把统计写进快照 */ + public static function snapStats(int $personId, string $mode, array $data): void + { + WeReadCache::put($personId, WeReadCache::statsKey($mode), $data); + } + + /** 快照的"数据更新于"时间(页面上用;0 = 从没同步过) */ + public static function snapshotAt(int $personId): int + { + return WeReadCache::updatedAt($personId, WeReadCache::K_NOTEBOOKS); + } + + /** @var array 本次请求已经排过后台刷新的(避免重复注册 shutdown 钩子) */ + private static array $bgQueued = []; + + /** + * 排一次"**响应发完之后**再去同步"。 + * + * 🔴 v29 用户第 4 条的另一半:光把数据落盘还不够 —— + * 原来那段自动同步挂在**渲染之前**(`maybeSync` 在 `View::render` 上面), + * 所以"6 小时到期的那一次"仍然要等它跑完才出页面。 + * 落盘解决了"每次都要去读",但没解决"那一 次要等"。 + * ⇒ 照 `Holiday::queueBackgroundFetch()` 的成熟做法: + * `fastcgi_finish_request()` 把响应**立刻发给浏览器**, + * 之后脚本继续跑同步、写快照,下次访问自然就是新的。 + * + * ⚠️ php-fpm 才有 `fastcgi_finish_request`;`php -S`(本地)没有, + * 那就是"同步仍在请求内发生" —— 与改造前一致,不会更差。 + * ⚠️ 用 `register_shutdown_function` 而不是在这里直接调: + * 必须等**页面已经渲染完**(输出都发出去)才切断,否则会把页面截断。 + * ⚠️ 同一个请求里只排一次(`$bgQueued`):读书页会连着调好几次取数。 + */ + public static function queueBackgroundSync(int $familyId, int $personId): void + { + if ($personId <= 0 || $familyId <= 0) { + return; + } + if (isset(self::$bgQueued[$personId])) { + return; + } + self::$bgQueued[$personId] = true; + register_shutdown_function(static function () use ($familyId, $personId): void { + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } + try { + // 走到这里时"间隔到没到"要重新判一次(queue 时不判、执行时判): + // 期间可能已经有别的请求同步过了,那就别再打一次接口。 + self::maybeSync($familyId, $personId, false); + } catch (\Throwable $e) { + // 后台任务绝不能把响应打挂(此时响应早已发完,报错只会进日志) + } + }); + } + /** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */ private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string { @@ -886,6 +1025,23 @@ final class WeRead $newNotes += (int)$r['added']; } } + /* 🔴 v29 用户第 4 条:同步完把**书清单与统计**一起落盘。 + 这一步是"落盘"方案的核心收益所在 —— 之后页面渲染只读这张快照, + 不再每次去问官方要(用户原话:「不用每次都读取微信读书的数据了」)。 + ⚠️ 放在这里(同步的末尾)而不是"每次保存某本书时": + 书清单是一份**整体**,逐本保存会让"某几次成功、某一次失败" + 留下一份拼凑的清单;这里存的是"这一轮真实扫到的完整清单"。 + ⚠️ 即使本轮因预算中途停下,也要落盘:清单本身就是 notebooks() 的 + 原始返回(完整、未被裁剪),"扫了几本"是另一件事(那是书架的补全进度)。 + ⚠️ 统计与清单都**只在自己成功时**才覆盖:拉失败就保留旧快照, + 宁可是旧数据,也不要把页面变成空的。 */ + if (!empty($nb['books'])) { + self::snapNotebooks($personId, (array)$nb['books']); + } + $st = self::readStats($personId, 'overall'); + if (!empty($st['ok'])) { + self::snapStats($personId, 'overall', (array)$st['data']); + } return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes, 'covers' => $newCovers, 'scanned' => $scanned]; } diff --git a/app/WeReadCache.php b/app/WeReadCache.php new file mode 100644 index 0000000..11ec931 --- /dev/null +++ b/app/WeReadCache.php @@ -0,0 +1,138 @@ + false, 'data' => null, 'updated_at' => 0, 'stale' => true]; + if ($personId <= 0 || $key === '') { + return $miss; + } + $db = Database::get(); + try { + $st = $db->prepare( + 'SELECT v, UNIX_TIMESTAMP(updated_at) AS ts + FROM weread_snapshots WHERE person_id = ? AND k = ?' + ); + $st->execute([$personId, $key]); + $row = $st->fetch(); + } catch (\Throwable $e) { + // 表还没建(升级中)→ 当作"没有快照",绝不让缓存层把页面打挂 + return $miss; + } + if (!$row) { + return $miss; + } + $ts = (int)($row['ts'] ?? 0); + $stale = (time() - $ts) > WeRead::SNAP_TTL; + if ($freshOnly && $stale) { + return $miss; + } + $data = json_decode((string)($row['v'] ?? ''), true); + return ['ok' => true, 'data' => $data, 'updated_at' => $ts, 'stale' => $stale]; + } + + /** 写一份快照(覆盖旧的)。失败静默 —— 缓存写不进去不该影响功能。 */ + public static function put(int $personId, string $key, $data): void + { + if ($personId <= 0 || $key === '') { + return; + } + $json = json_encode($data, JSON_UNESCAPED_UNICODE); + if ($json === false) { + return; + } + try { + $db = Database::get(); + $st = $db->prepare( + 'INSERT INTO weread_snapshots (person_id, k, v, updated_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)' + ); + $st->execute([$personId, $key, $json]); + } catch (\Throwable $e) { + // 静默(缓存是加速手段,不是功能本体) + } + } + + /** 最后一次落盘是什么时候(页面上显示"数据更新于 …") */ + public static function updatedAt(int $personId, string $key): int + { + return (int)self::get($personId, $key)['updated_at']; + } + + /** 这个人有没有**任何**快照(判断"是不是第一次用") */ + public static function hasAny(int $personId): bool + { + if ($personId <= 0) { + return false; + } + try { + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM weread_snapshots WHERE person_id = ?' + ); + $st->execute([$personId]); + return (int)$st->fetchColumn() > 0; + } catch (\Throwable $e) { + return false; + } + } + + /** 清掉一个人的全部快照(换 Key / 停用自动同步时用) */ + public static function forget(int $personId): void + { + if ($personId <= 0) { + return; + } + try { + Database::get()->prepare('DELETE FROM weread_snapshots WHERE person_id = ?') + ->execute([$personId]); + } catch (\Throwable $e) { + } + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index fa34070..7494d69 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1089,8 +1089,18 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, 以下都是 2026-09-30 那一批反馈加的东西 ============================================================ */ -/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */ -.sr-only { +/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) + 🔴 v29 用户第 2 条:「任务完成复选框的提示文字还在」—— + 真根因:`_task_check.php` 与任务看板用的是 `class="visually-hidden"`, + 而**本文件里从来没有这个类**(项目一直只定义 `.sr-only`)。 + CSS 里查不到的类 = 没有任何样式 → 那段文字是**普通 inline span,直接显示出来**, + 就贴在复选框旁边(用户看到的就是「标记完成:待办」)。 + ⇒ 修法:把两个名字指向同一条规则(两种写法都认)。 + 为什么不是"把模板改成 sr-only 了事":这个类名在下游(别的模板、以后新写的) + 同样可能被用到,名字不再是隐患;而只改调用点的话,下次谁再写 + `visually-hidden` 就又漏了。这里是"给能力补上定义",不是"给某一处打补丁"。*/ +.sr-only, +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } @@ -4611,17 +4621,23 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b .roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; } .roster-tagbar-clear { margin-left: auto; font-size: 12.5px; } -/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ---- - ⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、 - 窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。 - 用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */ -.roster.is-flat .people-grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 8px; -} -@media (max-width: 720px) { - .roster.is-flat .people-grid { grid-template-columns: 1fr; } -} +/* ---- 平铺列表:**仍然单列**(只是不再有"栏"的分隔线)---- + 🔴🔴 v29 用户第 3 条:「名册页面完全错乱」—— 这个 bug 是 v28 **我自己引入的**。 + v28 我把这里写成了多列 grid(`repeat(auto-fill, minmax(320px,1fr))`), + 真实后果有两个,都实测到了: + ① 卡片上有 `content-visibility: auto` + `contain-intrinsic-size: auto 68px` + (v24 为"千人名册不卡死"加的)。它在**单列流**里是安全的, + 一旦卡片变成 grid item,屏外卡片的"估算高度"与网格轨道互相打架, + 布局当场乱掉; + ② 每列只剩 ~339px(1280 宽 ÷ 3),而卡片里是 + [拖柄][头像 52px + 姓名/徽标][标签+编辑+删除 三个按钮 ≈213px] —— + 实测 `person-main` 只分到 **106px**(含 52px 头像 → 姓名区仅 42px), + 姓名、徽标全被挤碎,动作区还会折行。 + ⇒ 修法:**回到单列**(`.people-grid` 本来就是单列 flex,见文件上方 328 行), + 把 v28 那条 grid 规则整个删掉。名册人数从 3 到 1000+ 都是单列, + 与"分栏时代每一栏的卡片"同宽 —— 那才是这套卡片的原始设计宽度。 + ⚠️ 别再用"多列网格"来塞名册卡片:它的内容是**横向三段式**的, + 天生吃宽度;窄了就一定碎。 */ /* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」 原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。 @@ -4756,8 +4772,16 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b .note-ex-empty { color: var(--faint); font-style: italic; } /* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */ .note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); } +/* 🔴 v29 第 6 条:「笔记,首页显示的时候,里面的图片,和行程的类似, + 把图片按照比例缩放到固定的高度或者宽度,放在下面就行了。」 + ⇒ 固定高度 + **等比完整显示**(contain),不再 cover 裁切。 + ⚠️ 从 cover 改 contain 是有意的取舍: + cover 能让每张图铺满、看着"实",但**看不到完整内容**(横图两端被切掉)—— + 用户要的是"按比例缩小放在下面",那就是要看到**整张**。 + 代价是宽高比与格子不一致时两侧留浅底,所以给个底色让留白显得是有意的。 */ .note-thumb img { - width: 100%; height: 88px; object-fit: cover; display: block; + width: 100%; height: 110px; object-fit: contain; display: block; + background: #F6F4F0; border: 1px solid var(--line); border-radius: var(--radius-s); } .note-card .note-tags-inline { margin-top: auto; padding-top: 8px; flex: 0 0 auto; } @@ -4872,3 +4896,90 @@ h5.md-h, h6.md-h { font-size: 14px; } /* 姓名那一栏允许收缩并截断,否则长名字也会顶破 */ .pname { overflow-wrap: anywhere; } } +/* 🔴 v29 第 3 条(窄屏半边):360px 下卡片只有 328px, + [拖柄 24][头像 52 + 姓名][标签+编辑+删除 ≈197] 硬塞在一行 → 姓名区只剩 ~40px。 + ⇒ 窄屏把动作区**整体折到第二行**(卡片改纵向),姓名那一行就宽裕了。 + 用 flex-direction: column 而不是给动作区定宽:后者的宽度随标签数变化, + 定了也会被长标签顶开(v28 就是这么栽的)。 */ +@media (max-width: 640px) { + .person { flex-wrap: wrap; } + .person .person-main { flex: 1 1 calc(100% - 40px); } + .person-actions { + flex: 1 1 100%; justify-content: flex-start; + padding: 0 10px 10px 34px; /* 左边缩进到与姓名对齐(躲开拖柄) */ + } + .person-tag-btn { max-width: 46vw; } +} + +/* ============================================================ + v29 用户第 7 / 8 / 9 条:笔记 —— 阅读态 / 工具条 / 斜线命令 / 图片调大小 + ============================================================ */ + +/* ---- 阅读态(第 8 条:点进来默认是"看")---- + 与编辑态的区别要**一眼看得出**:没有虚线框、没有按钮,就是一篇排好版的文档。 */ +.note-read { max-width: 820px; } +.note-read-title { margin: 0 0 6px; font-size: 22px; font-weight: 600; color: var(--ink); } +.note-read-tags { margin: 0 0 14px; } +.note-read-body { display: flex; flex-direction: column; gap: 14px; } +.note-read-block { font-size: 15px; line-height: 1.8; color: var(--ink); } +/* 图片:默认满宽,可调(内联 style 给百分比);圆角与卡片一致 */ +.note-read-fig { margin: 0; } +.note-read-fig img { + max-width: 100%; height: auto; display: block; + border: 1px solid var(--line); border-radius: var(--radius-s); +} +.note-read-fig a { display: inline-block; max-width: 100%; } +.note-read-h2 { + margin: 0 0 8px; font-size: 14px; font-weight: 600; color: var(--muted); + letter-spacing: .5px; +} +/* 附件区:一条细线把它与正文分开("在最下方"要看得出来是**另一块**) */ +.note-read-files { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--line-2); } +.note-read .note-share-now { margin-top: 16px; } + +/* ---- 编辑态:Markdown 工具条(第 7 条"要么有工具条")---- */ +.note-cmdbar { + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; + padding: 6px 8px; margin-bottom: 8px; + border: 1px solid var(--line); border-radius: var(--radius-s); + background: #FDFCFA; +} +.note-cmdbar .cmd { + min-width: 28px; height: 26px; padding: 0 7px; + border: 1px solid var(--line); border-radius: 5px; background: #fff; + color: var(--ink); cursor: pointer; font-size: 13px; line-height: 1; +} +.note-cmdbar .cmd:hover { border-color: var(--brand-2); color: var(--brand-ink); background: var(--brand-soft, #FDF3EC); } +.note-cmdbar .cmd-sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; } +.note-cmdbar .note-live { margin-left: auto; } + +/* ---- 斜线命令菜单(第 7 条"要么有代码提示")---- + ⚠️ 绝对定位在**块内**(不是 body):跟着块走,块删除/移动时不会留孤儿菜单。 */ +.note-block-body { position: relative; } +.slash-menu { + position: absolute; left: 0; top: 100%; z-index: 30; display: none; + min-width: 190px; max-height: 220px; overflow-y: auto; + background: #fff; border: 1px solid var(--line); border-radius: var(--radius-s); + box-shadow: var(--shadow); padding: 4px; +} +.slash-menu.is-on { display: block; } +.slash-item { + display: flex; align-items: baseline; gap: 8px; width: 100%; + padding: 5px 8px; border: 0; border-radius: 5px; background: transparent; + cursor: pointer; text-align: left; font-size: 13px; color: var(--ink); +} +.slash-item b { color: var(--brand); font-family: ui-monospace, Consolas, monospace; } +.slash-item span { color: var(--muted); font-size: 12px; } +.slash-item:hover, .slash-item.is-on { background: var(--brand-soft, #FDF3EC); } + +/* ---- 图片块:尺寸档位(第 8 条"图文混排时,需要可以调整大小")---- */ +.note-img-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.note-img-sizes { display: inline-flex; gap: 3px; margin-left: auto; } +.note-img-size { + padding: 2px 7px; border: 1px solid var(--line); border-radius: 5px; + background: #fff; color: var(--muted); cursor: pointer; font-size: 11.5px; +} +.note-img-size:hover { border-color: var(--brand-2); color: var(--brand-ink); } +.note-img-size.is-on { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); color: var(--brand-ink); } +/* 图上标着当前档位,编辑器里一眼能看出"这张被缩过了" */ +.note-block[data-kind="image"] .note-block-img { transition: width .12s ease; } diff --git a/assets/js/note_editor.js b/assets/js/note_editor.js index 6dd002d..a7820ac 100644 --- a/assets/js/note_editor.js +++ b/assets/js/note_editor.js @@ -1,11 +1,13 @@ /** - * 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。 + * 个人笔记 · 分块编辑器(v27 起;v29 用户 2026-10-08 第 7/8 条大改)。 * - * 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明): - * ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea) - * ② 增(+段落 / +图片 / +清单)、删、上移、下移 + * 只做这几件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明): + * ① 把「块」渲染成一列可编辑行(**段落 textarea / 图片卡** —— v29 起没有清单块了) + * ② 增(+段落 / +图片)、删、上移、下移 * ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块 * ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks` + * ⑤ 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ **斜线命令**(行首打 `/`) + * ⑥ 🔴 v29 第 8 条:图片可以**调大小**(宽度 25/50/75/100%) * * ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里: * 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、 @@ -15,6 +17,10 @@ * * ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删, * 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。 + * + * 🔴 v29 第 8 条:**清单块去掉**(用户原话「清单功能不要了吧,markdown 里面 + * 包含这个功能」)。所以 `+清单` 按钮与 list 分支都没了 —— + * 想列清单就在段落里写 `- 项`(Markdown),渲染出来一样是列表。 */ (function () { 'use strict'; @@ -29,13 +35,70 @@ // 上传目标(哪个图片块在等这次上传的结果) var pendingBlock = null; + // media 出口的基址由模板注入(页面上的 `data-media-base`)—— + // 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。 + var mediaBase = (form.getAttribute('data-media-base') + || document.body.getAttribute('data-media-base') + || 'media.php?k='); + + // --------------------------------------------------------------------- + // 🔴 v29 第 7 条:Markdown 命令表(**工具条与斜线菜单共用这一份**) + // --------------------------------------------------------------------- + // + // ⚠️ 为什么只有一份:如果工具条和斜线各写一套,迟早出现"按钮能插、 + // 斜线插不了"或者"两处语法不一样"(这正是用户抱怨的"要记住所有命令")。 + // 每个命令:wrap = 用来包住选中文本(含 %s),或 insert = 无选中时插的模板。 + var CMDS = { + b: { word: '粗体', keys: ['bold', 'b'], wrap: '**%s**', tip: '加粗' }, + i: { word: '斜体', keys: ['italic', 'i'], wrap: '*%s*', tip: '斜体' }, + h2: { word: '标题', keys: ['h2', 'head'], wrap: '## %s', tip: '小标题' }, + ul: { word: '列表', keys: ['ul', 'list'], wrap: '- %s', tip: '列表项' }, + quote: { word: '引用', keys: ['quote', 'q'], wrap: '> %s', tip: '引用' }, + code: { word: '代码', keys: ['code'], wrap: '```\n%s\n```', tip: '代码块' }, + link: { word: '链接', keys: ['link', 'url'], wrap: '[%s](https://)', tip: '链接' }, + img: { word: '图片', keys: ['img'], insert: '+ 图片(点左侧「+ 图片」按钮选文件)' } + }; + + /** 在 textarea 的**光标处**套上/插入一段 Markdown。 */ + function applyCmd(ta, key) { + var c = CMDS[key]; + if (!ta || !c) { return; } + var v = ta.value; + var s = ta.selectionStart || 0; + var e = ta.selectionEnd || 0; + var sel = v.slice(s, e); + var out; + + if (c.insert) { + out = c.insert; + ta.value = v.slice(0, s) + out + v.slice(e); + ta.selectionStart = ta.selectionEnd = s + out.length; + } else if (sel !== '') { + // 有选中 → 包住它(多行时**逐行**套,这样"选中三行 → 列表"会得到三个列表项) + out = c.wrap.indexOf('%s') >= 0 + ? c.wrap.replace('%s', sel.indexOf('\n') >= 0 + ? sel.split('\n').map(function (l) { return l; }).join('\n') + : sel) + : sel + c.wrap; + ta.value = v.slice(0, s) + out + v.slice(e); + ta.selectionStart = s; + ta.selectionEnd = s + out.length; + } else { + // 没选中 → 插模板,光标落在 %s 处(用户接着打内容) + out = c.wrap; + var at = out.indexOf('%s'); + ta.value = v.slice(0, s) + out + v.slice(e); + ta.selectionStart = ta.selectionEnd = s + (at >= 0 ? at : out.length); + } + ta.focus(); + // 输入事件要**手动触发**:程序化改 value 浏览器不会发 input, + // 而预览与序列化都挂在那个事件上(不触发就会出现"插了但预览没变")。 + ta.dispatchEvent(new Event('input', { bubbles: true })); + } + // --------------------------------------------------------------------- // 渲染一块 // --------------------------------------------------------------------- - // media 出口的基址由模板注入(页面上的 `data-media-base`)—— -// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。 - var mediaBase = (document.body.getAttribute('data-media-base') || 'media.php?k='); - function makeBlock(kind, content) { var wrap = document.createElement('div'); wrap.className = 'note-block'; @@ -53,53 +116,75 @@ body.className = 'note-block-body'; if (kind === 'image') { - // 图片块:content 是附件 key。填 key 的同时把预览换出来。 + // 图片块:content 是附件 key(可带 `|宽度`)。填 key 的同时把预览换出来。 + var parts = String(content || '').split('|'); + var key = parts[0] || ''; + var w = parseInt(parts[1] || '0', 10) || 0; var img = document.createElement('img'); img.className = 'note-block-img'; img.alt = '笔记图片'; - img.src = content ? (mediaBase + encodeURIComponent(content)) : ''; - if (!content) { img.classList.add('is-empty'); } + img.src = key ? (mediaBase + encodeURIComponent(key)) : ''; + if (!key) { img.classList.add('is-empty'); } + applyImgWidth(img, w); + + var bar = document.createElement('div'); + bar.className = 'note-img-bar'; var hint = document.createElement('button'); hint.type = 'button'; hint.className = 'note-img-pick'; - hint.textContent = content ? '换一张' : '选图片'; + hint.textContent = key ? '换一张' : '选图片'; hint.setAttribute('data-op', 'pick'); + bar.appendChild(hint); + + /* 🔴 v29 第 8 条:「图文混排时,需要可以调整大小。」 + 四个档位(25 / 50 / 75 / 100%),点一下立即生效。 + ⚠️ 用**百分比**而不是像素:同一张图在手机与桌面上要按容器宽等比缩, + 写死 px 在窄屏会溢出去(本项目在名册上刚栽过一次溢出)。 */ + var sizes = document.createElement('span'); + sizes.className = 'note-img-sizes'; + [25, 50, 75, 100].forEach(function (pct) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'note-img-size' + ((pct === w || (pct === 100 && w === 0)) ? ' is-on' : ''); + b.setAttribute('data-op', 'size'); + b.setAttribute('data-pct', String(pct)); + b.textContent = pct + '%'; + sizes.appendChild(b); + }); + bar.appendChild(sizes); body.appendChild(img); - body.appendChild(hint); - body.setAttribute('data-key', content || ''); + body.appendChild(bar); + body.setAttribute('data-key', key); + body.setAttribute('data-w', String(w)); } else { var ta = document.createElement('textarea'); ta.className = 'input note-block-ta'; - ta.rows = kind === 'list' ? 4 : 3; + ta.rows = 4; ta.value = content || ''; - ta.placeholder = kind === 'list' - // v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层) - ? '一行一项;行首缩进两个空格 = 子项(可多级)' - : '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`'; + ta.placeholder = '写点什么…支持 Markdown:**粗体** *斜体* # 标题 - 列表 > 引用 `代码`(行首打 / 选命令)'; body.appendChild(ta); - /* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。 - 做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。 - ⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移 - (预览是粗体、保存后不是),而用户看到的就是预览。 - 清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大, - 反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */ - /* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。 - 段落:原文直接渲染。 - 清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留, - 于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。 - 这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里), - 不必为清单再写一套层级解析(两套必然漂移)。 */ - if (kind === 'text' || kind === 'list') { - var pv = document.createElement('div'); - pv.className = 'note-block-pv'; - pv.setAttribute('data-pv', '1'); - body.appendChild(pv); - } + /* 段落下方的实时预览("所见即所得")。 + ⚠️ v29 起**只有段落**这一种文本块了(清单并入 Markdown), + 所以这里不再需要按 kind 分支。 */ + var pv = document.createElement('div'); + pv.className = 'note-block-pv'; + pv.setAttribute('data-pv', '1'); + body.appendChild(pv); } wrap.appendChild(body); return wrap; } + /** 把宽度百分比应用到 (0 或 100 都不写 style,用默认 100%) */ + function applyImgWidth(img, pct) { + if (!img) { return; } + if (pct > 0 && pct < 100) { + img.style.width = pct + '%'; + } else { + img.style.width = ''; + } + } + /* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */ var pvTimer = 0; function renderPreview(block) { @@ -110,15 +195,7 @@ if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; } var md = ta.value; if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; } - // 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级 - var block = pv.closest ? pv.closest('.note-block') : null; - if (block && block.getAttribute('data-kind') === 'list') { - md = md.split(/\r?\n/).map(function (line) { - return line.trim() === '' ? '' : ('- ' + line.replace(/^(\s*)[-*+]\s+/, '$1')); - }).join('\n'); - } - var form = document.getElementById('noteForm'); - var tok = form ? form.querySelector('input[name="_csrf"]') : null; + var tok = form.querySelector('input[name="_csrf"]'); var fd = new FormData(); fd.append('md', md); if (tok) { fd.append('_csrf', tok.value); } @@ -150,7 +227,15 @@ var content; if (kind === 'image') { var b = el.querySelector('.note-block-body'); - content = (b && b.getAttribute('data-key')) || ''; + if (!b) { return; } + var key = b.getAttribute('data-key') || ''; + var w = parseInt(b.getAttribute('data-w') || '0', 10) || 0; + // 宽度只在 20~99 时写进内容(100 是默认,不必写) + /* ⚠️ 宽度只在**有图**时才写进 content(`key|50`)——没有 key 的 + `|50` 是无效内容(服务端会当空块丢掉,用户看到"刚选的尺寸没了")。 + 但**尺寸本身要留住**(存在 data-w 上):用户可能先点 50% 再选图, + 选完图那一刻 serialize 会带上宽度(见下面)。 */ + content = (key !== '' && w > 20 && w < 100) ? (key + '|' + w) : key; } else { var ta = el.querySelector('textarea'); content = ta ? ta.value : ''; @@ -189,7 +274,7 @@ // 事件委托:统一处理块上的所有按钮 list.addEventListener('click', function (e) { - var btn = e.target.closest('[data-op]'); + var btn = e.target.closest ? e.target.closest('[data-op]') : null; if (!btn || !list.contains(btn)) { return; } var op = btn.getAttribute('data-op'); var el = btn.closest('.note-block'); @@ -204,10 +289,38 @@ fileIn.click(); return; } + if (op === 'size') { + // 🔴 v29 第 8 条:调图片大小(就地生效 + 存进块内容) + var b = el.querySelector('.note-block-body'); + var pct = parseInt(btn.getAttribute('data-pct') || '100', 10) || 100; + b.setAttribute('data-w', String(pct)); + applyImgWidth(el.querySelector('.note-block-img'), pct); + Array.prototype.forEach.call(el.querySelectorAll('.note-img-size'), function (x) { + x.classList.toggle('is-on', x === btn); + }); + serialize(); + return; + } }); - // 顶部「+段落 / +图片 / +清单」 - document.querySelectorAll('.note-add [data-add]').forEach(function (b) { + /* 🔴 v29 第 7 条:工具条(点一下插到光标处)。 + ⚠️ 作用对象是**最后聚焦的那个 textarea**(`lastTa`)—— 点按钮时焦点已经 + 离开 textarea 了,光看 `document.activeElement` 是拿不到它的。 */ + var lastTa = null; + list.addEventListener('focusin', function (e) { + if (e.target && e.target.tagName === 'TEXTAREA') { lastTa = e.target; } + }); + Array.prototype.forEach.call(document.querySelectorAll('[data-cmdbar] [data-cmd]'), + function (b) { + b.addEventListener('click', function () { + var ta = lastTa || list.querySelector('textarea'); + applyCmd(ta, b.getAttribute('data-cmd')); + if (ta) { schedulePreview(ta.closest('.note-block')); } + }); + }); + + // 顶部「+段落 / +图片」 + Array.prototype.forEach.call(document.querySelectorAll('.note-add [data-add]'), function (b) { b.addEventListener('click', function () { var kind = b.getAttribute('data-add'); if (kind === 'image') { @@ -221,6 +334,161 @@ }); }); + /* --------------------------------------------------------------------- + 🔴 v29 第 7 条:**斜线命令**(行首打 `/` 弹出菜单) + --------------------------------------------------------------------- + 用户原话:「例如插入一个斜线,然后输入代码,例如 code, + 就能自动补全 code 的 markdown 代码。」 + ⇒ 在段落 textarea 里:光标所在的**整行**是 `/xxx` 形态时弹出候选; + 继续打字是**过滤**,回车/点击选中即执行那个命令。 + + ⚠️⚠️ 判据挂在 `input` 事件上,**不是** `keydown('/')` —— + 第一版写的是"按下 `/` 时 setTimeout(0) 再回头看光标前一个字符是不是 `/`"。 + 实测**必现失败**:打字快时(或粘贴、或中文输入法上屏、或自动化测试的 + `type()` 连打)等到那个 timeout 跑起来,光标前一个字符早就是 `o` 了, + 于是菜单**永远不弹** —— 用户看到的就是"这个功能没做"。 + 正确姿势:每次输入后看**整行**(行首到光标)是不是 `/` + 若干词字符。 + 这样无论打得多快、是不是一次粘贴进来,判断都成立。 */ + function slashContext(ta) { + var v = ta.value; + var pos = ta.selectionStart; + if (pos !== ta.selectionEnd) { return null; } // 有选区时不弹 + var before = v.slice(0, pos); + var lineStart = before.lastIndexOf('\n') + 1; + var line = before.slice(lineStart); + var m = /^\/([A-Za-z]*)$/.exec(line); + if (!m) { return null; } + return {start: lineStart, q: m[1].toLowerCase()}; + } + + function closeSlash() { + if (slashState && slashState.box && slashState.box.parentNode) { + slashState.box.parentNode.removeChild(slashState.box); + } + slashState = null; + } + + function slashItemsFor(q) { + var out = []; + Object.keys(CMDS).forEach(function (k) { + var c = CMDS[k]; + var hit = k.indexOf(q) === 0 || c.word.indexOf(q) === 0 + || (c.keys || []).some(function (x) { return x.indexOf(q) === 0; }); + if (hit) { out.push([k, c]); } + }); + return out; + } + + function openSlash(ta, ctx) { + var box = document.createElement('div'); + box.className = 'slash-menu'; + // 挂在块上(不是 body):跟着块走,块被删/移动时不会留下孤儿菜单 + var host = ta.closest('.note-block-body') || ta.parentNode; + host.appendChild(box); + slashState = { ta: ta, box: box, start: ctx.start, items: [] }; + renderSlash(ctx.q); + } + + /** 当前打开的斜线菜单状态:{ta, box, start, items};null = 没开 */ + var slashState = null; + + /** 输入过程中同步菜单:该弹就弹、该换词就换词、不该弹就收起来 */ + function syncSlash(ta) { + var ctx = slashContext(ta); + if (!ctx) { closeSlash(); return; } + var items = slashItemsFor(ctx.q); + if (!items.length) { closeSlash(); return; } + if (!slashState || slashState.ta !== ta) { openSlash(ta, ctx); return; } + slashState.start = ctx.start; + renderSlash(ctx.q); + } + + function renderSlash(q) { + if (!slashState) { return; } + var items = slashItemsFor(q); + slashState.items = items; + var box = slashState.box; + box.innerHTML = ''; + if (!items.length) { box.classList.remove('is-on'); return; } + box.classList.add('is-on'); + items.forEach(function (it, i) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'slash-item' + (i === 0 ? ' is-on' : ''); + b.setAttribute('data-slash', it[0]); + b.innerHTML = '/' + it[0] + '' + it[1].tip + ''; + b.addEventListener('mousedown', function (ev) { + ev.preventDefault(); // 别让 textarea 失焦(失焦就取不到选区了) + runSlash(it[0]); + }); + box.appendChild(b); + }); + } + + function runSlash(key) { + if (!slashState) { return; } + var ta = slashState.ta; + var box = slashState.box; + // 先把 `/xxx` 这段从 textarea 里剪掉(它只是命令,不该留在正文里) + var v = ta.value; + var upto = ta.selectionStart; + var cut = v.slice(0, slashState.start) + v.slice(upto); + ta.value = cut; + ta.selectionStart = ta.selectionEnd = slashState.start; + // 关掉菜单(先关再执行,applyCmd 会重新 focus) + if (box && box.parentNode) { box.parentNode.removeChild(box); } + slashState = null; + applyCmd(ta, key); + schedulePreview(ta.closest('.note-block')); + } + + list.addEventListener('keydown', function (e) { + var ta = e.target; + if (!ta || ta.tagName !== 'TEXTAREA') { return; } + + if (slashState && slashState.ta === ta) { + if (e.key === 'Escape') { e.preventDefault(); closeSlash(); return; } + if (e.key === 'Enter' || e.key === 'Tab') { + if (slashState.items.length) { + e.preventDefault(); // 别让它换行 + runSlash(slashState.items[0][0]); + return; + } + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + var bs = Array.prototype.slice.call(slashState.box.querySelectorAll('.slash-item')); + if (bs.length > 1) { + var cur = bs.findIndex(function (x) { return x.classList.contains('is-on'); }); + var nxt = ((cur + (e.key === 'ArrowDown' ? 1 : -1)) % bs.length + bs.length) % bs.length; + bs.forEach(function (x, i) { x.classList.toggle('is-on', i === nxt); }); + } + return; + } + } + }); + + // 输入时:刷新斜线过滤 + 预览 + list.addEventListener('input', function (e) { + serialize(); + var ta = e.target; + if (!ta || ta.tagName !== 'TEXTAREA') { return; } + var block = ta.closest('.note-block'); + + // 🔴 v29 第 7 条:斜线菜单在**每次输入后**按"整行是不是 /xxx"重判 + // (见 slashContext 的注释:挂在 keydown('/') 上会漏掉快速输入/粘贴) + syncSlash(ta); + if (block && block.getAttribute('data-kind') === 'text') { + schedulePreview(block); + } + }); + list.addEventListener('blur', function (e) { + // 失焦就关菜单(点别处了)。用 0ms 让"点击候选"先跑完(它是 mousedown)。 + if (slashState && e.target === slashState.ta) { + window.setTimeout(closeSlash, 120); + } + }, true); + // --------------------------------------------------------------------- // 图片上传 // --------------------------------------------------------------------- @@ -258,6 +526,8 @@ var img = el.querySelector('.note-block-img'); if (img) { img.src = d.url; img.classList.remove('is-empty'); } if (pick) { pick.textContent = '换一张'; } + // ⚠️ 上传完要立刻 serialize:此时 key 才有了值,宽度(data-w)才写进 content。 + // 否则"先点 50% 再选图"的人,尺寸要到下次改动才生效。 serialize(); }).catch(function () { if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; } @@ -267,20 +537,7 @@ fileIn.value = ''; }); - // textarea 输入时同步(这样即便用户直接点提交,内容也是最新的) - // + v28:段落块顺手刷新 Markdown 实时预览 - list.addEventListener('input', function (e) { - serialize(); - var ta = e.target; - if (ta && ta.tagName === 'TEXTAREA') { - var block = ta.closest ? ta.closest('.note-block') : null; - if (block && block.getAttribute('data-kind') === 'text') { - schedulePreview(block); - } - } - }); - - /* 实时预览开关(用户第 12 条:"允许所见即所得编辑,也允许 markdown 编辑")—— + /* 实时预览开关("允许所见即所得编辑,也允许 markdown 编辑")—— 关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */ var liveBox = document.getElementById('noteLivePreview'); if (liveBox) { @@ -325,7 +582,7 @@ initial.forEach(function (b) { addBlock(b.kind, b.content); }); serialize(); - /* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下 + /* 编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下 键盘才第一次看到"所见即所得",会以为这功能没生效)。 */ Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'), function (b) { renderPreview(b); }); @@ -334,14 +591,15 @@ // 标签联想:点一下把 #标签 追加进输入框 // --------------------------------------------------------------------- var tagIn = document.getElementById('noteTags'); - document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) { - b.addEventListener('click', function () { - if (!tagIn) { return; } - var t = '#' + b.getAttribute('data-tag'); - var cur = tagIn.value.trim(); - // 已经有就不重复加 - if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; } - tagIn.value = cur === '' ? t : (cur + ' ' + t); + Array.prototype.forEach.call(document.querySelectorAll('.note-tags-suggest [data-tag]'), + function (b) { + b.addEventListener('click', function () { + if (!tagIn) { return; } + var t = '#' + b.getAttribute('data-tag'); + var cur = tagIn.value.trim(); + // 已经有就不重复加 + if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; } + tagIn.value = cur === '' ? t : (cur + ' ' + t); + }); }); - }); })(); \ No newline at end of file diff --git a/index.php b/index.php index db9e6d1..d07f688 100644 --- a/index.php +++ b/index.php @@ -5444,8 +5444,10 @@ $want = array_keys($want); // ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是 // 读一个 user_settings 时间戳,不联网。 // ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。 + // 🔴 v29 用户第 4 条:这里也改成**响应之后**再同步。 + // 这条路由是用户日常最常打开的入口,"点进来要等"的感觉主要来自它。 if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) { - WeRead::maybeSync($familyId, $me, false); + WeRead::queueBackgroundSync($familyId, $me); } $f = [ 'status' => (string)($_GET['status'] ?? ''), @@ -5837,11 +5839,15 @@ $want = array_keys($want); } if ($act === 'fetch') { // 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定 + // 🔴 v29 用户第 4 条:这里是**用户主动点的按钮**,所以仍然实时取 + // ("我点一下就要看最新的"是明确预期,不能拿缓存糊弄)。 + // 但取完**顺手落盘** —— 之后列表页/页头统计就走本地快照了。 $nb = WeRead::notebooks($me); if (!$nb['ok']) { flash('err', (string)$nb['error']); redirect('books/weread'); } + WeRead::snapNotebooks($me, (array)$nb['books']); $preview = []; foreach (array_slice($nb['books'], 0, 40) as $b) { $nt = WeRead::notes($me, (string)$b['bookId']); @@ -5945,20 +5951,29 @@ $want = array_keys($want); redirect('books'); } } - // ---- GET:先"顺手同步一次"(用户第 8 条:定期自动同步)---- + // ---- GET:**不在渲染路径上同步**(v29 用户第 4 条)---- // - // ⚠️ 位置很要紧:放在**渲染之前**,这样这一页看到的书架就是最新的; - // 而 maybeSync 内部按间隔判定,绝大多数请求只是读一个时间戳(不联网)。 + // 🔴 用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用 + // 每次都读取微信读书的数据了。」 + // v28 只是给同步加了"总预算"(8 秒),但那 8 秒**仍然挡在渲染前面** + // (这一段原来就在 View::render 上方)—— 到期那一次还是要等。 + // ⇒ 现在两件事一起做: + // ① 取数改走**本地快照**(`*Cached()`),永不联网; + // ② 该同步时**排到响应之后**去跑(queueBackgroundSync),页面立刻出来。 $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); + WeRead::queueBackgroundSync((int)Auth::familyId(), $me); } View::render('books/weread', [ 'title' => '微信读书导入', 'preview' => $imported, 'nbCount' => 0, 'sync' => $syncRes, - 'stats' => WeRead::readStats($me, 'overall'), + // ⚠️ 本地没有快照时(第一次用 / 刚换 Key)允许真取一次: + // 否则页面会是一片空白,用户会以为"坏了"。 + // 这是**首次**,之后每次访问都命中本地 —— 那才是常态。 + 'stats' => WeRead::readStatsCached($me, 'overall')['data'], + 'snapAt' => WeRead::snapshotAt($me), ], 'books'); break; @@ -6050,7 +6065,21 @@ $want = array_keys($want); // 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路: // 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。 $act = (string)($_POST['act'] ?? ''); - $ok = static function (string $msg): void { + /* 🔴 v29 用户第 5 条:设置拆成四项之后,保存完要**回到刚才那一项**, + 不能一律跳回「我的网址」—— 那会让人以为"设置没保存"(保存后位置变了)。 + 约定:表单里带一个 `back` 隐藏域(= 当前 `?set=` 的值)。 + ⚠️ 白名单校验:`back` 只允许是那四个已知值,别的值一律当空 + (否则它就是一个开源的重定向参数,能被塞任意 URL)。 + ⚠️ 站点相关动作(site_save/delete…)默认 `back` 为空 → 回「我的网址」, + 这正是不带 `set` 时的默认视图,符合直觉。 */ + $backRaw = (string)($_POST['back'] ?? ''); + $backSet = in_array($backRaw, ['addr', 'general', 'board', 'engine'], true) + ? $backRaw : ''; + // 拼"回去哪儿"。⚠️ 用 redirect() 的同一套 url() 拼法(路由 + 附加参数), + // 别手写 'nav&set=xxx' 字符串 —— 那样与模板里 url('nav',['set'=>...]) 的 + // 转义口径就可能不一致(本项目在"斜杠被 urlencode"上栽过一次)。 + $navBackUrl = $backSet === '' ? url('nav') : url('nav', ['set' => $backSet]); + $ok = static function (string $msg) use ($navBackUrl): void { // AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302 if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { header('Content-Type: application/json; charset=utf-8'); @@ -6058,16 +6087,18 @@ $want = array_keys($want); exit; } flash('ok', $msg); - redirect('nav'); + header('Location: ' . $navBackUrl); + exit; }; - $bad = static function (string $msg): void { + $bad = static function (string $msg) use ($navBackUrl): void { if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); exit; } flash('err', $msg); - redirect('nav'); + header('Location: ' . $navBackUrl); + exit; }; $gid = (int)($_POST['id'] ?? 0); @@ -6357,29 +6388,62 @@ $want = array_keys($want); 'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag), 'showAll' => $showAll, 'limit' => Note::LIST_LIMIT, + // 卡片上要显示"共享给几个人"(一次查完,别每条一次) + 'shareN' => Note::shareCounts(array_map( + static fn($r): int => (int)$r['id'], $rows)), ], 'notes'); break; - /* 新建 / 编辑:同一套表单(`id>0` 就是编辑)。 - ⚠️ 编辑时**只能改自己的**:别人的笔记即使共享给全家也只是"看得到", - 点进编辑页会被挡回列表(不是静默变成新建,那样用户会以为改成功了)。 */ + /* 笔记页:**默认是阅读态**(v29 用户第 8 条),点「编辑」才进编辑态。 + · `?id=` 看某条(阅读态,图文混排,附件在最下方) + · `?id=&edit=1` 编辑某条 + · 无 `id` 新建(直接就是编辑态 —— 还没有内容可"读") + + 🔴 用户原话:「笔记点进来,默认是阅读状态,图文混排,附件在最下方, + 图文混排时,需要可以调整大小。点击编辑按钮,才进入编辑功能。」 + ⇒ 这一条把 v27/v28 的"点进来就是编辑器"整个反过来: + 阅读是主路径(笔记多数时间是被看的),编辑是次路径(少数时间被改)。 + + ⚠️ 权限:能看到就能进(阅读态);能不能**改**由 Note::canWrite 决定 + (作者本人 或 共享时给了 write)。不能改就只看得到「编辑」按钮之外的 + 内容 —— 按钮不显示,服务端也会挡(两端都判,不只靠前端藏按钮)。 */ case 'notes/form': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); - Auth::requireWrite(); - $id = (int)($_GET['id'] ?? 0); - $editing = null; + $id = (int)($_GET['id'] ?? 0); + + // 新建必须能写;看/改已有笔记时**不 requireWrite**(访客也能看共享给他们的) + if ($id <= 0) { + Auth::requireWrite(); + } + $note = null; + $perm = ''; if ($id > 0) { - $editing = Note::find($familyId, $id, $me); - if ($editing === null || empty($editing['is_mine'])) { - flash('err', '只能编辑自己的笔记。'); + $note = Note::find($familyId, $id, $me); + if ($note === null) { + flash('err', '这条笔记看不到(可能已删除,或者没有共享给你)。'); redirect('notes'); } + $perm = Note::permOf($note, $me); } + $canEdit = $id <= 0 ? \App\Auth::canWrite() : Note::canWrite($note ?? [], $me); + // `?edit=1` 且真的有写权限 → 编辑态;否则一律阅读态 + $wantEdit = !empty($_GET['edit']); + if ($id > 0 && $wantEdit && !$canEdit) { + flash('err', '这条笔记你只有只读权限。'); + redirect('notes/form', ['id' => $id]); + } + $editMode = $id <= 0 || $wantEdit; View::render('notes/form', [ - 'title' => $editing ? '改笔记' : '写笔记', - 'editing' => $editing, + 'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : '看笔记', + 'editing' => $note, + 'editMode' => $editMode, + 'perm' => $perm, + 'canEdit' => $canEdit, + 'shares' => $id > 0 ? Note::shares($id) : [], 'tagSuggest' => Note::tagSuggest($familyId, $me), + // 共享要"选人"(v29 第 9 条,与资源页同一个选人组件) + 'people' => Person::roster($familyId, $me, 'member', ''), ], 'notes'); break; @@ -6407,19 +6471,24 @@ $want = array_keys($want); } } $payload = [ - 'title' => (string)($_POST['title'] ?? ''), - 'blocks' => $blocks, - 'tags' => (string)($_POST['tags'] ?? ''), - 'is_shared' => !empty($_POST['is_shared']), + 'title' => (string)($_POST['title'] ?? ''), + 'blocks' => $blocks, + 'tags' => (string)($_POST['tags'] ?? ''), ]; $id = (int)($_POST['id'] ?? 0); if ($id > 0) { $done = Note::update($familyId, $id, $me, $payload); - flash($done ? 'ok' : 'err', $done ? '改好了。' : '只能改自己的笔记。'); - redirect('notes'); + flash($done ? 'ok' : 'err', $done ? '改好了。' : '这条笔记你没有写权限。'); + // 🔴 v29 第 8 条:保存后回到**阅读态**,让用户立刻看到改完的样子 + // (而不是停在编辑器里,让人不确定到底存上没存上)。 + redirect('notes/form', ['id' => $id]); } $nid = Note::create($familyId, $me, $payload); - flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '什么都没写,没记。'); + if ($nid > 0) { + flash('ok', '记下了。'); + redirect('notes/form', ['id' => $nid]); + } + flash('err', '什么都没写,没记。'); redirect('notes'); break; @@ -6429,9 +6498,11 @@ $want = array_keys($want); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); - $n = Note::find($familyId, $id, $me); - if (!$n || empty($n['is_mine'])) { - flash('err', '只能删自己的笔记。'); + $n = Note::find($familyId, $id, $me); + // 🔴 v29:删除**只限作者本人** —— "可读写"给的是编辑权,不是销毁权 + // (与资源的语义一致:写入受限、删除始终归本人/管理员)。 + if (!$n || (int)$n['person_id'] !== $me) { + flash('err', '只有作者本人能删这条笔记。'); redirect('notes'); } Note::destroy($familyId, $id, $me); @@ -6444,6 +6515,49 @@ $want = array_keys($want); 共享出去的笔记别人只能看,不该能往里塞文件。 ⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单), 不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */ + /* ---- 共享(v29 用户第 9 条):选人 + 权限,与资源同一套 ---- + 与资源的 resources/share 路由同一个形状:先设一条(人 + 只读/读写), + 取消共享走另一条(unshare)。表单全页只有一份,前端填隐藏域提交。 */ + case 'notes/share': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $pid = (int)($_POST['person_id'] ?? 0); + $n = Note::find($familyId, $id, $me); + // ⚠️ 只有**作者**能改共享(拿到 write 权限的人不该能把笔记再转给别人 —— + // 那等于把作者的分享范围悄悄扩大)。 + if (!$n || (int)$n['person_id'] !== $me) { + flash('err', '只有作者本人能改共享设置。'); + redirect('notes'); + } + if ($pid <= 0) { + flash('err', '得先选一个要共享给谁。'); + redirect('notes/form', ['id' => $id]); + } + Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read')); + flash('ok', '共享已更新。'); + redirect('notes/form', ['id' => $id]); + break; + + case 'notes/unshare': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $pid = (int)($_POST['person_id'] ?? 0); + $n = Note::find($familyId, $id, $me); + if (!$n || (int)$n['person_id'] !== $me) { + flash('err', '只有作者本人能改共享设置。'); + redirect('notes'); + } + Note::dropShare($id, $pid); + flash('ok', '已取消共享。'); + redirect('notes/form', ['id' => $id]); + break; + case 'notes/attach_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); diff --git a/templates/home.php b/templates/home.php index 156a7d3..b720527 100644 --- a/templates/home.php +++ b/templates/home.php @@ -470,14 +470,13 @@ $showNow = $nowPos >= 0 && $nowPos <= 1; 用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」 ⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。 - ⚠️ 触发条件只看**网格在不在**(`$timed` 非空才有网格),**不要**再加 `$showNow`。 - 第一版写了 `if (!empty($timed) && $showNow)`,后果是: - · 红线落在区间外(现在 23:40、日程只到 18 点 → 页面不画红线)时**整段脚本不渲染** - → "每次进来都要定位"这条需求在最常见的场景(晚上开首页)直接失效; - · 更糟的是**判据依赖了被优化掉的东西**:探针那次库里没有分时段日程, - 脚本根本没输出,页面看着"没报错",实测 `scrollTop=0`(本轮实测抓到的)。 - ⇒ 现在:**有网格就定位**。红线在就对齐红线;红线不在(在区间外)就用 - `$nowPos` 同一个比例算出"此刻"在网格里的位置来对齐(数值同源,不会两套口径)。 + ⚠️ 触发条件:**今天有日程就定位**(只要 `$todayEvents` 非空,页面上就有 + `.ag-grid` 与红线)。曾经写成 `if (!$todayEvents || empty($timed))`, + 后果是**今天只有全天日程 / 全天日程都没有分时段的那种日子,脚本整段不输出** + —— 而网格(0–24h)和红线照样渲染,于是"红线在,页面却停在最上面" + (v29 用户第 1 条仍然报「没有定位到红线」,实测 `scrollTop=0`、脚本不在页面上, + 就是这个条件卡掉的;有分时段日程时定位其实完全正确、偏差 0px)。 + ⇒ 只判"有没有网格可滚",别判"是不是分时段日程"。 ⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`: 它会**连带滚动外层页面**(把整个浏览器窗口也滚下去), @@ -486,8 +485,8 @@ $showNow = $nowPos >= 0 && $nowPos <= 1; ⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。 ⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 —— 两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?> - - + + +
+
+ + +
+
+ + +
+
+

正文

+
+ + +
+
+ + +
+ + + + + + + + + + 行首打 / 也能选命令 + +
+ +
+ + + + + + +
+ + +
+
+ + + +
+ 用过的: + + + +
+ +
+ +
+ + + +
+

附件 放最后,与正文分开

+ + +
+ + +
+ + + + + + +
+ +
+ +

还没有附件。

+ + +
+ + +
+ + 把文件拖到这里,或 +
+
+ +
+
+
+ +
+

附件

+

先「记下来」,保存之后回到这一页就能传附件了。

+
+ + +
+ + + 取消 +
+ + + + + + + + 而不是 data-* —— + 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> + \ No newline at end of file diff --git a/templates/notes/index.php b/templates/notes/index.php index 3c7d32b..b9831cf 100644 --- a/templates/notes/index.php +++ b/templates/notes/index.php @@ -93,7 +93,9 @@ $canWrite = \App\Auth::canWrite(); $firstImg = ''; foreach ((array)$n['blocks'] as $b) { if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') { - $firstImg = (string)$b['content']; + // ⚠️ 图片块内容可能是 `key|50`(v29 第 8 条:可调宽度)。 + // 取 key 时要把 `|宽度` 去掉,否则拿它当 key 查会 404。 + $firstImg = trim(explode('|', (string)$b['content'])[0]); break; } } @@ -101,8 +103,14 @@ $canWrite = \App\Auth::canWrite();
- - 共享 + + 0): ?> + 共享 私有 @@ -110,7 +118,7 @@ $canWrite = \App\Auth::canWrite(); - 改
@@ -122,6 +130,7 @@ $canWrite = \App\Auth::canWrite();
+

@@ -133,7 +142,13 @@ $canWrite = \App\Auth::canWrite();
- +
diff --git a/templates/people/index.php b/templates/people/index.php index 6cb9f1b..fc69ff3 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -211,7 +211,18 @@ $renderGrid = function (array $list) use ($meId, $canMove): void { data-pm-hint="" aria-label="更多操作" title="更多操作">⋯ -
+ + `(闭合 `.person`)在 v29 重写平铺列表时**漏掉了**。 + 浏览器遇到"卡片没闭合"会**把下一张卡塞进上一张里**(DOM 修复行为), + 于是每张卡的宽度被前一张的按钮吃掉 —— 实测第一张的 `.person-main` + 只剩 24~151px(本该 ~800px),姓名与徽标全被挤碎, + 整页看起来就是"错乱"。 + ⚠️ 这类"少一个闭合标签"的 bug,`php -l` 与静态断言**全绿** + (PHP 语法没错、PHP 的 if/endif 也平衡),只有真机量 DOM 才现形。 + 定位手法:量 `.person` 的 `children`,发现里面**有另一个 `.person`** + —— 嵌套的同类元素 = 上一张没收尾。 */ ?>