v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享

前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .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;迁移用一次性哨兵。
This commit is contained in:
2026-10-08 10:54:43 +08:00
parent 6852b4ee2c
commit 37ba86321b
12 changed files with 1714 additions and 346 deletions
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* 微信读书远端数据的**本地快照**(v29 用户 2026-10-08 第 4 条)。
*
* 用户原话:
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
* 微信读书的数据了。」
*
* ────────────────────────────────────────────────────────────────
* 为什么单开一个类、而不是塞进 `WeRead`:
* `WeRead` 的头部写得很清楚 ——「这个类**只负责取数据**,不碰数据库」。
* 那条约定是有价值的(网络与存储分开,测试可以只测取数不连外网)。
* 落盘是**另一件事**,所以放另一个类;`WeRead` 保持"干净的网络层"。
*
* 为什么是"JSON 快照"而不是"拆成表":
* 这些数据是**只读的远端结果**,字段完全由官方决定(说改就改),
* 本站从不按其中任何字段做查询、排序、JOIN。
* 拆表 = 把别人的字段设计背到自己身上,官方一变就要写迁移;
* 存 JSON = 官方怎么变都能原样兜住(渲染端按需取,取不到就当空)。
*
* 为什么渲染路径**永远不等**刷新(关键设计):
* `get()` 只读本地,永不联网。若快照过期,调用方可以"顺手"触发一次刷新,
* 但**必须先拿旧快照把页面渲染出来**,刷新结果留到下次访问可见。
* 反面做法是"过期就同步完再渲染" —— 那就又回到"点进来要等很久",
* 也就是用户这次报的那个问题。**"数据新鲜"永远排在"页面能立刻打开"之后。**
*/
declare(strict_types=1);
namespace App;
final class WeReadCache
{
/** 快照的数据集名 —— 只在这里登记,别处一律用常量,避免拼错字符串 */
public const K_NOTEBOOKS = 'notebooks';
/** 阅读统计(按 mode 各存一份:overall / weekly / monthly / annually) */
public static function statsKey(string $mode): string
{
return 'stats_' . $mode;
}
/**
* 读一份快照。
*
* @param bool $freshOnly true = 过期就当没有(返回 null)——
* 给那些"宁可不显示也不要旧数据"的场景用
* @return array{ok:bool,data:mixed,updated_at:int,stale:bool}
* ok=false 表示**本地没有**(从没同步过)
*/
public static function get(int $personId, string $key, bool $freshOnly = false): array
{
$miss = ['ok' => 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) {
}
}
}