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
+145 -31
View File
@@ -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();