Files
FamilyBook/templates/notes/form.php
T
laoyang 37ba86321b 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;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00

388 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 个人笔记 · 一条笔记的两种面貌(v29 用户 2026-10-08 第 6/7/8/9 条)。
*
* 🔴 用户原话(第 8 条):
* 「笔记点进来,默认是阅读状态,图文混排,附件在最下方,图文混排时,
* 需要可以调整大小。点击编辑按钮,才进入编辑功能。
* 清单功能不要了吧,markdown 里面包含这个功能,把段落做的完善一点就可以了。」
* ⇒ 这个模板现在有**两个态**:
* · **阅读态**(`$editMode = false`):图文按顺序排下来、附件在最底下、
* 右上角一个「编辑」按钮。多数时候用户是来看的。
* · **编辑态**(`$editMode = true`):分块编辑器 + 工具条 + Markdown 补全。
*
* 🔴 用户原话(第 7 条):
* 「添加段落部分,还是不太好,这样就意味着用户还是要记住所有的 markdown 命令,
* 要么有工具条,要么有代码提示,例如插入一个斜线,然后输入代码,例如 code,
* 就能自动补全 code 的 markdown 代码。」
* ⇒ **两样都给**(用户原话就是"要么…要么…",那两样都给最省事):
* · 工具条:粗体 / 斜体 / 标题 / 列表 / 引用 / 代码 / 链接 —— 点一下插到光标处;
* · 斜线提示:行首打 `/` 弹出命令菜单,打 `code` 回车就补上代码块。
*
* ⚠️ 为什么"阅读态"要单独一份渲染(而不是复用编辑器的预览):
* 编辑器里的预览是"每块一个方框、带虚线边框",那是**编辑**的视觉语言;
* 阅读态要的是干净的排版(没有框、没有按钮)。两者混用会让阅读页
* 看着像"还没保存的草稿"。
* ⚠️ 图片"可以调整大小"(第 8 条):用 width 百分比存进图片块的内容里
* (`key|50`),渲染时按它出宽度。见下面 `$imgW` 的解析与注释。
*
* @param array|null $editing 笔记(阅读/编辑都以它为准);新建时 null
* @param bool $editMode true = 编辑态
* @param string $perm 'owner' / 'write' / 'read' / ''
* @param bool $canEdit 能不能改(作者或 write)
* @param array $shares 已共享给谁(作者视角才有内容)
* @param array $tagSuggest 我用过的标签(点一下就加)
*/
$editing = $editing ?? null;
$isEditing = $editing !== null;
$editMode = (bool)($editMode ?? ($isEditing ? false : true));
$canWrite = \App\Auth::canWrite();
$perm = (string)($perm ?? '');
$canEdit = (bool)($canEdit ?? false);
$shares = (array)($shares ?? []);
$isMine = $isEditing && (int)$editing['person_id'] === (int)\App\Auth::personId();
// 把编辑中的数据整理成前端要的形状(块数组 + 标签词)
$blocks = [];
$tagsStr = '';
if ($isEditing) {
foreach ((array)$editing['blocks'] as $b) {
// ⚠️ 历史 list 块在编辑态里**转成 Markdown 文本**再放进编辑器:
// 否则编辑器认不出这种块(v29 已把清单并入 Markdown),
// 会渲染成一个空的方块 —— 用户一保存内容就丢了。
$k = (string)$b['kind'];
$c = (string)$b['content'];
if ($k === 'list') {
$c = \App\Note::blockHtmlOut($c); // 见 Note::blockHtmlOut(返回 Markdown 文本)
$k = 'text';
}
$blocks[] = ['kind' => $k, 'content' => $c];
}
$tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
}
if (!$blocks) {
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
$blocks = [['kind' => 'text', 'content' => '']];
}
/** 图片块内容 → [key, 宽度百分比]。内容形如 `abc123` 或 `abc123|50`。 */
$imgParts = static function (string $c): array {
$p = explode('|', $c);
$w = isset($p[1]) ? (int)$p[1] : 0;
if ($w < 20 || $w > 100) { $w = 0; }
return [trim((string)$p[0]), $w];
};
?>
<div class="page-head">
<h1><?= $editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记' ?></h1>
<span class="sub">
<?php if ($isEditing && !$editMode): ?>
<?= e(substr((string)$editing['updated_at'], 0, 16)) ?>
<?php if (!$isMine): ?>
· 作者 <?= e((string)($editing['author_name'] ?? '')) ?>
· 你的权限:<?= e(\App\Note::permWord($perm)) ?>
<?php endif; ?>
<?php endif; ?>
</span>
<div class="actions">
<?php /* 🔴 v29 第 8 条:阅读态的右上角就是那个「编辑」按钮 —— 用户明确说
"点击编辑按钮,才进入编辑功能"。 */ ?>
<?php if ($isEditing && !$editMode && $canEdit && $canWrite): ?>
<a class="btn btn-sm" href="<?= e(url('notes/form', ['id' => (int)$editing['id'], 'edit' => 1])) ?>">编辑</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">返回列表</a>
</div>
</div>
<?php if (!$isEditing && !$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
<?php elseif (!$editMode): ?>
<?php /* ==================== ✅ 阅读态(默认)====================
用户第 8 条要的"图文混排 + 附件在最下方",就在这一段。
⚠️ 图片块用 `<figure>` 包:宽度可调(第 8 条),且要与正文有间距。
⚠️ 附件(`$editing['files']`)**排在正文之后、页面最下方** ——
用户原话「附件在最下方」。 */ ?>
<article class="note-read card card-pad">
<?php $title = trim((string)($editing['title'] ?? '')); ?>
<?php if ($title !== ''): ?>
<h1 class="note-read-title"><?= e($title) ?></h1>
<?php endif; ?>
<?php if (!empty($editing['tags'])): ?>
<div class="note-tags note-read-tags">
<?php foreach ((array)$editing['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">#<?= e((string)$t) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="note-read-body">
<?php foreach ((array)$editing['blocks'] as $b): ?>
<?php $k = (string)$b['kind']; $c = (string)$b['content']; ?>
<?php if ($k === 'image'): ?>
<?php [$key, $w] = $imgParts($c); ?>
<?php if ($key !== ''): ?>
<figure class="note-read-fig">
<a href="<?= e(media_url($key)) ?>" data-prev>
<img src="<?= e(media_url($key)) ?>" alt=""
<?= $w > 0 ? 'style="width:' . (int)$w . '%"' : '' ?>
loading="lazy" decoding="async">
</a>
</figure>
<?php endif; ?>
<?php else: ?>
<div class="note-read-block md-body"><?= (string)($b['html'] ?? '') ?></div>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!$editing['blocks']): ?>
<p class="empty-hint">(这条笔记没有正文)</p>
<?php endif; ?>
</div>
<?php /* ---- 附件:永远在最后 ---- */ ?>
<?php if (!empty($editing['files'])): ?>
<section class="note-read-files">
<h2 class="note-read-h2">附件</h2>
<div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
<?= e((string)($f['name'] ?? '附件')) ?>
</a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</article>
<?php /* ==================== 共享(v29 第 9 条:选人 + 权限,同资源)====================
🔴 只在**作者**视角显示:共享范围是作者的决定,拿到只读的人不该看到名单。
交互与资源页完全一致:选人 → 选权限 → 共享;已共享的逐个可「取消共享」。 */ ?>
<?php if ($isMine && $canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2">共享
<span class="hint">默认只有自己看得到;共享出去给别人看或改</span></h2>
<form method="post" action="<?= e(url('notes/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="field">
<label>共享给谁</label>
<?php
$ppName = 'person_id';
$ppPeople = $people ?? [];
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">你自己本来就有全部权限,不用(也不能)共享给自己。</span>
</div>
<div class="field">
<label>权限</label>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-ok" type="submit">共享</button>
</div>
</form>
<div class="note-share-now">
<h3 class="note-read-h2">已共享给</h3>
<?php if ($shares): ?>
<div class="note-files">
<?php foreach ($shares as $s): ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Note::permWord((string)$s['perm'])) ?></span>
<span class="note-file-nm"><?= e((string)$s['name']) ?></span>
<form method="post" action="<?= e(url('notes/unshare')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button type="submit" class="note-file-del" title="取消共享">取消共享</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint">还没有共享给谁。</p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<?php /* ==================== ✏️ 编辑态 ==================== */ ?>
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
<?php /* ⚠️ media 地址**不在表单上注入**:`media_url('')` 返回的是默认头像
(见 helpers.php 里 `$key === ''` 那一支),拿它当基址会拼出
`avatar-default.svgabc123` 这种地址。基址统一由 layout 的
`<body data-media-base="media.php?k=">` 提供,脚本读它就行。 */ ?>
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<?php /* 块的真实载荷(由页尾脚本维护) */ ?>
<input type="hidden" name="blocks" id="noteBlocks" value="">
<div class="card card-pad">
<div class="field">
<label for="noteTitle">标题</label>
<input class="input" id="noteTitle" name="title" maxlength="200"
value="<?= e((string)($editing['title'] ?? '')) ?>"
placeholder="一句话概括(也可以不写,只写正文)" autocomplete="off">
</div>
</div>
<?php /* ---------------- 分块编辑区 ---------------- */ ?>
<div class="card card-pad">
<div class="note-block-head">
<h2 class="cinema-h2">正文</h2>
<div class="note-add">
<button class="btn btn-ghost btn-sm" type="button" data-add="text">+ 段落</button>
<button class="btn btn-ghost btn-sm" type="button" data-add="image">+ 图片</button>
</div>
</div>
<?php /* 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ 斜线命令提示。
工具条按钮与斜线菜单是**同一份命令表**(见 note_editor.js 的 CMDS),
避免"按钮能插、斜线插不了"这种两套实现。 */ ?>
<div class="note-cmdbar" data-cmdbar>
<button type="button" class="cmd" data-cmd="b" title="粗体 **文字**"><b>B</b></button>
<button type="button" class="cmd" data-cmd="i" title="斜体 *文字*"><i>I</i></button>
<span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="h2" title="小标题 ## 文字">H</button>
<button type="button" class="cmd" data-cmd="ul" title="列表 - 项目">≡</button>
<button type="button" class="cmd" data-cmd="quote" title="引用 > 文字">❝</button>
<button type="button" class="cmd" data-cmd="code" title="代码 ```">&#60;/&#62;</button>
<button type="button" class="cmd" data-cmd="link" title="链接 [文字](地址)">🔗</button>
<span class="cmd-sep"></span>
<span class="small muted">行首打 <code>/</code> 也能选命令</span>
<label class="note-live">
<input type="checkbox" id="noteLivePreview" checked>
<span>实时预览</span>
</label>
</div>
<div id="noteBlockList" class="note-blocks"></div>
<?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?>
<noscript>
<div class="field" style="margin-top:10px">
<label for="notePlain">正文(纯文本)</label>
<textarea class="input" id="notePlain" name="body_plain" rows="8"
placeholder="没有启用脚本时,可以在这里写纯文字正文"><?php
echo e(implode("\n\n", array_map(
static fn($b): string => (string)$b['content'], $blocks)));
?></textarea>
</div>
</noscript>
<?php /* 图片上传用的隐藏 file 框(点"+ 图片"时程序化触发) */ ?>
<input type="file" id="noteImgInput" accept="image/*" hidden>
</div>
<?php /* ---------------- 标签 ---------------- */ ?>
<div class="card card-pad">
<div class="field">
<label for="noteTags">标签</label>
<input class="input" id="noteTags" name="tags" maxlength="300"
value="<?= e($tagsStr) ?>"
placeholder="用空格分开,如:读书 灵感 待办(可写 # 也可不写)" autocomplete="off">
<?php if ($tagSuggest): ?>
<div class="note-tags note-tags-suggest">
<span class="small muted">用过的:</span>
<?php foreach ($tagSuggest as $t): ?>
<button type="button" class="note-tag" data-tag="<?= e((string)$t) ?>">
#<?= e((string)$t) ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php /* ⚠️ v29:原来的「共享给全家」勾选框**删掉**了 ——
共享改成"保存后在阅读态选人 + 选权限"(与资源一致,见第 9 条)。 */ ?>
</div>
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
⇒ 附件区放在**整张表单的最下面**。
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。 */ ?>
<?php if ($isEditing): ?>
<div class="card card-pad" id="noteFilesCard">
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
<?php if (!empty($editing['files'])): ?>
<div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
<?= e((string)($f['name'] ?? '附件')) ?>
</a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
<button type="button" class="note-file-del" data-note-file-del="<?= (int)$f['id'] ?>"
title="删掉这个附件">✕</button>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint" id="noteFilesEmpty">还没有附件。</p>
<?php endif; ?>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('notes/attach_add')) ?>" id="noteAttForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop">
<input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>
</div>
<div class="btn-row">
<button class="btn btn-ghost btn-sm" type="submit">上传所选文件</button>
</div>
</form>
</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<p class="empty-hint">先「记下来」,保存之后回到这一页就能传附件了。</p>
</div>
<?php endif; ?>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<?php /* 取消:编辑已有笔记时回它的阅读态;新建时回列表 */ ?>
<a class="btn btn-ghost"
href="<?= e($isEditing ? url('notes/form', ['id' => (int)$editing['id']]) : url('notes')) ?>">取消</a>
</div>
</form>
<?php if ($isEditing): ?>
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
避免"每行一份 form"那种页面膨胀)。 */ ?>
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
</form>
<?php endif; ?>
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode(
$blocks,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<?php endif; ?>