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
+157 -1
View File
@@ -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<int,bool> 本次请求已经排过后台刷新的(避免重复注册 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];
}