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
+234 -31
View File
@@ -43,8 +43,15 @@ final class Note
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */ /** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
public const ATTACH = 'note'; public const ATTACH = 'note';
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */ /** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
public const BLOCK_KINDS = ['text', 'image', 'list']; *
* 🔴 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; 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 $limit = 0,
int $offset = 0 int $offset = 0
): array { ): 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, $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 CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine
FROM notes n FROM notes n
JOIN persons p ON p.id = n.person_id JOIN persons p ON p.id = n.person_id
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; WHERE n.family_id = ? AND ' . $vis;
$bind = [$viewer, $familyId, $viewer]; // ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
$bind = [$viewer, $familyId, $viewer, $viewer];
$kw = trim($keyword); $kw = trim($keyword);
if ($kw !== '') { if ($kw !== '') {
@@ -123,8 +156,8 @@ final class Note
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
{ {
$sql = 'SELECT COUNT(*) FROM notes n $sql = 'SELECT COUNT(*) FROM notes n
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; WHERE n.family_id = ? AND ' . self::VIS_COND;
$bind = [$familyId, $viewer]; $bind = [$familyId, $viewer, $viewer];
$kw = trim($keyword); $kw = trim($keyword);
if ($kw !== '') { if ($kw !== '') {
$sql .= ' AND (n.title LIKE ? $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" "SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
); );
foreach ($st->fetchAll() as $b) { foreach ($st->fetchAll() as $b) {
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本) $b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
$b['html'] = (string)$b['kind'] === 'text'
? Markdown::render((string)$b['content'])
: '';
$blocks[(int)$b['note_id']][] = $b; $blocks[(int)$b['note_id']][] = $b;
} }
@@ -228,9 +258,9 @@ final class Note
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
FROM notes n JOIN persons p ON p.id = n.person_id 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)' 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]); $st->execute([$id, $familyId, $viewer, $viewer]);
$r = $st->fetch(); $r = $st->fetch();
if (!$r) { if (!$r) {
return null; return null;
@@ -296,11 +326,7 @@ final class Note
$st->execute([$noteId]); $st->execute([$noteId]);
$rows = $st->fetchAll(); $rows = $st->fetchAll();
foreach ($rows as &$r) { foreach ($rows as &$r) {
$kind = (string)$r['kind']; $r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
$r['html'] = $kind === 'text'
? Markdown::render((string)$r['content'])
: '';
} }
unset($r); unset($r);
return $rows; return $rows;
@@ -329,10 +355,10 @@ final class Note
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT t.tag, COUNT(DISTINCT n.id) AS n 'SELECT t.tag, COUNT(DISTINCT n.id) AS n
FROM note_tags t JOIN notes n ON n.id = t.note_id 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' GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
); );
$st->execute([$familyId, $viewer]); $st->execute([$familyId, $viewer, $viewer]);
return $st->fetchAll(); return $st->fetchAll();
} }
@@ -369,13 +395,15 @@ final class Note
return 0; return 0;
} }
$db = Database::get(); $db = Database::get();
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
$db->prepare( $db->prepare(
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at) 'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
VALUES (?,?,?,?,NOW(),NOW())' VALUES (?,?,?,0,NOW(),NOW())'
)->execute([ )->execute([
$familyId, $personId, $familyId, $personId,
mb_substr($title, 0, 200, 'UTF-8'), mb_substr($title, 0, 200, 'UTF-8'),
!empty($d['is_shared']) ? 1 : 0,
]); ]);
$id = (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
self::writeBlocks($id, $blocks); 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 public static function update(int $familyId, int $id, int $personId, array $d): bool
{ {
$db = Database::get(); $db = Database::get();
$st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?'); // 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
$st->execute([$id, $familyId, $personId]); $note = self::find($familyId, $id, $personId);
if (!$st->fetchColumn()) { if (!$note || !self::canWrite($note, $personId)) {
return false; return false;
} }
$title = trim((string)($d['title'] ?? '')); $title = trim((string)($d['title'] ?? ''));
@@ -404,9 +439,11 @@ final class Note
if ($title === '' && !$blocks) { if ($title === '' && !$blocks) {
$title = '(空笔记)'; $title = '(空笔记)';
} }
$db->prepare( // ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。
'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]); // 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。
$db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?')
->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]);
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值, // 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。 // 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
self::writeBlocks($id, $blocks); self::writeBlocks($id, $blocks);
@@ -455,6 +492,95 @@ final class Note
)->execute([$familyId]); )->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 一起走。 */ /** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
public static function destroy(int $familyId, int $id, int $personId): void 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,由模板直接出 <img>)
*/
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) * · 内容裁剪(去首尾空白;图片块 content = 附件 key)
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段) * · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
* · 上限 MAX_BLOCKS * · 上限 MAX_BLOCKS
* *
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
* 把段落做的完善一点就可以了。」
* ⇒ 块类型收成 `text` / `image` 两种。
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
* 而用户以后可以在它上面用 Markdown 继续写。
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
*
* @return array<int,array{kind:string,content:string}> * @return array<int,array{kind:string,content:string}>
*/ */
private static function cleanBlocks($raw): array private static function cleanBlocks($raw): array
@@ -522,6 +718,13 @@ final class Note
$kind = 'text'; $kind = 'text';
} }
$content = (string)($b['content'] ?? ''); $content = (string)($b['content'] ?? '');
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
if ($kind === 'list') {
$content = self::listToMarkdown($content);
$kind = 'text';
}
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查) // 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
$content = $kind === 'image' $content = $kind === 'image'
? trim(preg_replace('/\s+/', '', $content) ?? '') ? trim(preg_replace('/\s+/', '', $content) ?? '')
+110 -1
View File
@@ -108,6 +108,22 @@ final class Schema
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点), * ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。 * 启动时把已有分组**逐一迁成同名标签**(幂等:按 (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() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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 CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")' 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, 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) { if (self::$stepErrors) {
+157 -1
View File
@@ -91,6 +91,22 @@ final class WeRead
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */ /** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
public const SYNC_MAX_BOOKS = 25; public const SYNC_MAX_BOOKS = 25;
/**
* 快照的**保鲜期**(秒)—— 默认 24 小时。
*
* 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘,
* 这样就不用每次都读取微信读书的数据了。」
*
* ⇒ 落盘之后,"多久算旧"就成了新问题:
* · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘;
* · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。
* 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的),
* 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。
* ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。
* 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。
*/
public const SNAP_TTL = 86400;
// ================================================================ // ================================================================
// Key 的存取(加密) // Key 的存取(加密)
// ================================================================ // ================================================================
@@ -568,7 +584,8 @@ final class WeRead
return !empty($up['ok']) ? (string)$up['file'] : ''; return !empty($up['ok']) ? (string)$up['file'] : '';
} }
/** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */ /**
* 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
private static function safeBase(string $s): string private static function safeBase(string $s): string
{ {
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? ''; $s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
@@ -576,6 +593,128 @@ final class WeRead
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8'); 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,绝不抛) */ /** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
{ {
@@ -886,6 +1025,23 @@ final class WeRead
$newNotes += (int)$r['added']; $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, return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
'covers' => $newCovers, 'scanned' => $scanned]; 'covers' => $newCovers, 'scanned' => $scanned];
} }
+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) {
}
}
}
+125 -14
View File
@@ -1089,8 +1089,18 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
以下都是 2026-09-30 那一批反馈加的东西 以下都是 2026-09-30 那一批反馈加的东西
============================================================ */ ============================================================ */
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */ /* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉)
.sr-only { 🔴 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; position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; 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-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; }
.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; } .roster-tagbar-clear { margin-left: auto; font-size: 12.5px; }
/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ---- /* ---- 平铺列表:**仍然单列**(只是不再有"栏"的分隔线)----
⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、 🔴🔴 v29 用户第 3 条:「名册页面完全错乱」—— 这个 bug 是 v28 **我自己引入的**。
窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。 v28 我把这里写成了多列 grid(`repeat(auto-fill, minmax(320px,1fr))`),
用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */ 真实后果有两个,都实测到了:
.roster.is-flat .people-grid { ① 卡片上有 `content-visibility: auto` + `contain-intrinsic-size: auto 68px`
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); (v24 为"千人名册不卡死"加的)。它在**单列流**里是安全的,
gap: 8px; 一旦卡片变成 grid item,屏外卡片的"估算高度"与网格轨道互相打架,
} 布局当场乱掉;
@media (max-width: 720px) { ② 每列只剩 ~339px(1280 宽 ÷ 3),而卡片里是
.roster.is-flat .people-grid { grid-template-columns: 1fr; } [拖柄][头像 52px + 姓名/徽标][标签+编辑+删除 三个按钮 ≈213px] ——
} 实测 `person-main` 只分到 **106px**(含 52px 头像 → 姓名区仅 42px),
姓名、徽标全被挤碎,动作区还会折行。
⇒ 修法:**回到单列**(`.people-grid` 本来就是单列 flex,见文件上方 328 行),
把 v28 那条 grid 规则整个删掉。名册人数从 3 到 1000+ 都是单列,
与"分栏时代每一栏的卡片"同宽 —— 那才是这套卡片的原始设计宽度。
⚠️ 别再用"多列网格"来塞名册卡片:它的内容是**横向三段式**的,
天生吃宽度;窄了就一定碎。 */
/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」 /* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」
原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。 原来三颗按钮全是 .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-ex-empty { color: var(--faint); font-style: italic; }
/* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */ /* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */
.note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); } .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 { .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); 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; } .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; } .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; }
+332 -74
View File
@@ -1,11 +1,13 @@
/** /**
* 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。 * 个人笔记 · 分块编辑器(v27 起;v29 用户 2026-10-08 第 7/8 条大改)。
* *
* 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明): * 只做这几件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
* ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea) * ① 把「块」渲染成一列可编辑行(**段落 textarea / 图片卡** —— v29 起没有清单块了)
* ② 增(+段落 / +图片 / +清单)、删、上移、下移 * ② 增(+段落 / +图片)、删、上移、下移
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块 * ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks` * ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
* ⑤ 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ **斜线命令**(行首打 `/`)
* ⑥ 🔴 v29 第 8 条:图片可以**调大小**(宽度 25/50/75/100%)
* *
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里: * ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、 * 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
@@ -15,6 +17,10 @@
* *
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删, * ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。 * 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
*
* 🔴 v29 第 8 条:**清单块去掉**(用户原话「清单功能不要了吧,markdown 里面
* 包含这个功能」)。所以 `+清单` 按钮与 list 分支都没了 ——
* 想列清单就在段落里写 `- 项`(Markdown),渲染出来一样是列表。
*/ */
(function () { (function () {
'use strict'; 'use strict';
@@ -29,13 +35,70 @@
// 上传目标(哪个图片块在等这次上传的结果) // 上传目标(哪个图片块在等这次上传的结果)
var pendingBlock = null; 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) { function makeBlock(kind, content) {
var wrap = document.createElement('div'); var wrap = document.createElement('div');
wrap.className = 'note-block'; wrap.className = 'note-block';
@@ -53,53 +116,75 @@
body.className = 'note-block-body'; body.className = 'note-block-body';
if (kind === 'image') { 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'); var img = document.createElement('img');
img.className = 'note-block-img'; img.className = 'note-block-img';
img.alt = '笔记图片'; img.alt = '笔记图片';
img.src = content ? (mediaBase + encodeURIComponent(content)) : ''; img.src = key ? (mediaBase + encodeURIComponent(key)) : '';
if (!content) { img.classList.add('is-empty'); } 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'); var hint = document.createElement('button');
hint.type = 'button'; hint.type = 'button';
hint.className = 'note-img-pick'; hint.className = 'note-img-pick';
hint.textContent = content ? '换一张' : '选图片'; hint.textContent = key ? '换一张' : '选图片';
hint.setAttribute('data-op', 'pick'); 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(img);
body.appendChild(hint); body.appendChild(bar);
body.setAttribute('data-key', content || ''); body.setAttribute('data-key', key);
body.setAttribute('data-w', String(w));
} else { } else {
var ta = document.createElement('textarea'); var ta = document.createElement('textarea');
ta.className = 'input note-block-ta'; ta.className = 'input note-block-ta';
ta.rows = kind === 'list' ? 4 : 3; ta.rows = 4;
ta.value = content || ''; ta.value = content || '';
ta.placeholder = kind === 'list' ta.placeholder = '写点什么…支持 Markdown:**粗体** *斜体* # 标题 - 列表 > 引用 `代码`(行首打 / 选命令)';
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
? '一行一项;行首缩进两个空格 = 子项(可多级)'
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
body.appendChild(ta); body.appendChild(ta);
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。 /* 段落下方的实时预览("所见即所得")。
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。 ⚠️ v29 起**只有段落**这一种文本块了(清单并入 Markdown),
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移 所以这里不再需要按 kind 分支。 */
(预览是粗体、保存后不是),而用户看到的就是预览。 var pv = document.createElement('div');
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大, pv.className = 'note-block-pv';
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */ pv.setAttribute('data-pv', '1');
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。 body.appendChild(pv);
段落:原文直接渲染。
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
不必为清单再写一套层级解析(两套必然漂移)。 */
if (kind === 'text' || kind === 'list') {
var pv = document.createElement('div');
pv.className = 'note-block-pv';
pv.setAttribute('data-pv', '1');
body.appendChild(pv);
}
} }
wrap.appendChild(body); wrap.appendChild(body);
return wrap; return wrap;
} }
/** 把宽度百分比应用到 <img>(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)---- */ /* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
var pvTimer = 0; var pvTimer = 0;
function renderPreview(block) { function renderPreview(block) {
@@ -110,15 +195,7 @@
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; } if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
var md = ta.value; var md = ta.value;
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; } if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级 var tok = form.querySelector('input[name="_csrf"]');
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 fd = new FormData(); var fd = new FormData();
fd.append('md', md); fd.append('md', md);
if (tok) { fd.append('_csrf', tok.value); } if (tok) { fd.append('_csrf', tok.value); }
@@ -150,7 +227,15 @@
var content; var content;
if (kind === 'image') { if (kind === 'image') {
var b = el.querySelector('.note-block-body'); 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 { } else {
var ta = el.querySelector('textarea'); var ta = el.querySelector('textarea');
content = ta ? ta.value : ''; content = ta ? ta.value : '';
@@ -189,7 +274,7 @@
// 事件委托:统一处理块上的所有按钮 // 事件委托:统一处理块上的所有按钮
list.addEventListener('click', function (e) { 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; } if (!btn || !list.contains(btn)) { return; }
var op = btn.getAttribute('data-op'); var op = btn.getAttribute('data-op');
var el = btn.closest('.note-block'); var el = btn.closest('.note-block');
@@ -204,10 +289,38 @@
fileIn.click(); fileIn.click();
return; 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;
}
}); });
// 顶部「+段落 / +图片 / +清单」 /* 🔴 v29 第 7 条:工具条(点一下插到光标处)。
document.querySelectorAll('.note-add [data-add]').forEach(function (b) { ⚠️ 作用对象是**最后聚焦的那个 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 () { b.addEventListener('click', function () {
var kind = b.getAttribute('data-add'); var kind = b.getAttribute('data-add');
if (kind === 'image') { 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 = '<b>/' + it[0] + '</b><span>' + it[1].tip + '</span>';
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'); var img = el.querySelector('.note-block-img');
if (img) { img.src = d.url; img.classList.remove('is-empty'); } if (img) { img.src = d.url; img.classList.remove('is-empty'); }
if (pick) { pick.textContent = '换一张'; } if (pick) { pick.textContent = '换一张'; }
// ⚠️ 上传完要立刻 serialize:此时 key 才有了值,宽度(data-w)才写进 content。
// 否则"先点 50% 再选图"的人,尺寸要到下次改动才生效。
serialize(); serialize();
}).catch(function () { }).catch(function () {
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; } if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
@@ -267,20 +537,7 @@
fileIn.value = ''; fileIn.value = '';
}); });
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的) /* 实时预览开关("允许所见即所得编辑,也允许 markdown 编辑")——
// + 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 编辑")——
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */ 关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
var liveBox = document.getElementById('noteLivePreview'); var liveBox = document.getElementById('noteLivePreview');
if (liveBox) { if (liveBox) {
@@ -325,7 +582,7 @@
initial.forEach(function (b) { addBlock(b.kind, b.content); }); initial.forEach(function (b) { addBlock(b.kind, b.content); });
serialize(); serialize();
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下 /* 编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */ 键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'), Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
function (b) { renderPreview(b); }); function (b) { renderPreview(b); });
@@ -334,14 +591,15 @@
// 标签联想:点一下把 #标签 追加进输入框 // 标签联想:点一下把 #标签 追加进输入框
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
var tagIn = document.getElementById('noteTags'); var tagIn = document.getElementById('noteTags');
document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) { Array.prototype.forEach.call(document.querySelectorAll('.note-tags-suggest [data-tag]'),
b.addEventListener('click', function () { function (b) {
if (!tagIn) { return; } b.addEventListener('click', function () {
var t = '#' + b.getAttribute('data-tag'); if (!tagIn) { return; }
var cur = tagIn.value.trim(); var t = '#' + b.getAttribute('data-tag');
// 已经有就不重复加 var cur = tagIn.value.trim();
if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; } // 已经有就不重复加
tagIn.value = cur === '' ? t : (cur + ' ' + t); if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; }
tagIn.value = cur === '' ? t : (cur + ' ' + t);
});
}); });
});
})(); })();
+145 -31
View File
@@ -5444,8 +5444,10 @@ $want = array_keys($want);
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是 // ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
// 读一个 user_settings 时间戳,不联网。 // 读一个 user_settings 时间戳,不联网。
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。 // ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
// 🔴 v29 用户第 4 条:这里也改成**响应之后**再同步。
// 这条路由是用户日常最常打开的入口,"点进来要等"的感觉主要来自它。
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) { if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
WeRead::maybeSync($familyId, $me, false); WeRead::queueBackgroundSync($familyId, $me);
} }
$f = [ $f = [
'status' => (string)($_GET['status'] ?? ''), 'status' => (string)($_GET['status'] ?? ''),
@@ -5837,11 +5839,15 @@ $want = array_keys($want);
} }
if ($act === 'fetch') { if ($act === 'fetch') {
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定 // 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
// 🔴 v29 用户第 4 条:这里是**用户主动点的按钮**,所以仍然实时取
// ("我点一下就要看最新的"是明确预期,不能拿缓存糊弄)。
// 但取完**顺手落盘** —— 之后列表页/页头统计就走本地快照了。
$nb = WeRead::notebooks($me); $nb = WeRead::notebooks($me);
if (!$nb['ok']) { if (!$nb['ok']) {
flash('err', (string)$nb['error']); flash('err', (string)$nb['error']);
redirect('books/weread'); redirect('books/weread');
} }
WeRead::snapNotebooks($me, (array)$nb['books']);
$preview = []; $preview = [];
foreach (array_slice($nb['books'], 0, 40) as $b) { foreach (array_slice($nb['books'], 0, 40) as $b) {
$nt = WeRead::notes($me, (string)$b['bookId']); $nt = WeRead::notes($me, (string)$b['bookId']);
@@ -5945,20 +5951,29 @@ $want = array_keys($want);
redirect('books'); 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' => '']; $syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) { 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', [ View::render('books/weread', [
'title' => '微信读书导入', 'title' => '微信读书导入',
'preview' => $imported, 'preview' => $imported,
'nbCount' => 0, 'nbCount' => 0,
'sync' => $syncRes, 'sync' => $syncRes,
'stats' => WeRead::readStats($me, 'overall'), // ⚠️ 本地没有快照时(第一次用 / 刚换 Key)允许真取一次:
// 否则页面会是一片空白,用户会以为"坏了"。
// 这是**首次**,之后每次访问都命中本地 —— 那才是常态。
'stats' => WeRead::readStatsCached($me, 'overall')['data'],
'snapAt' => WeRead::snapshotAt($me),
], 'books'); ], 'books');
break; break;
@@ -6050,7 +6065,21 @@ $want = array_keys($want);
// 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路: // 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路:
// 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。 // 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。
$act = (string)($_POST['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 // AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -6058,16 +6087,18 @@ $want = array_keys($want);
exit; exit;
} }
flash('ok', $msg); 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') { if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit; exit;
} }
flash('err', $msg); flash('err', $msg);
redirect('nav'); header('Location: ' . $navBackUrl);
exit;
}; };
$gid = (int)($_POST['id'] ?? 0); $gid = (int)($_POST['id'] ?? 0);
@@ -6357,29 +6388,62 @@ $want = array_keys($want);
'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag), 'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag),
'showAll' => $showAll, 'showAll' => $showAll,
'limit' => Note::LIST_LIMIT, 'limit' => Note::LIST_LIMIT,
// 卡片上要显示"共享给几个人"(一次查完,别每条一次)
'shareN' => Note::shareCounts(array_map(
static fn($r): int => (int)$r['id'], $rows)),
], 'notes'); ], 'notes');
break; break;
/* 新建 / 编辑:同一套表单(`id>0` 就是编辑)。 /* 笔记页:**默认是阅读态**(v29 用户第 8 条),点「编辑」才进编辑态。
⚠️ 编辑时**只能改自己的**:别人的笔记即使共享给全家也只是"看得到", · `?id=` 看某条(阅读态,图文混排,附件在最下方)
点进编辑页会被挡回列表(不是静默变成新建,那样用户会以为改成功了)。 */ · `?id=&edit=1` 编辑某条
· 无 `id` 新建(直接就是编辑态 —— 还没有内容可"读")
🔴 用户原话:「笔记点进来,默认是阅读状态,图文混排,附件在最下方,
图文混排时,需要可以调整大小。点击编辑按钮,才进入编辑功能。」
⇒ 这一条把 v27/v28 的"点进来就是编辑器"整个反过来:
阅读是主路径(笔记多数时间是被看的),编辑是次路径(少数时间被改)。
⚠️ 权限:能看到就能进(阅读态);能不能**改**由 Note::canWrite 决定
(作者本人 或 共享时给了 write)。不能改就只看得到「编辑」按钮之外的
内容 —— 按钮不显示,服务端也会挡(两端都判,不只靠前端藏按钮)。 */
case 'notes/form': case 'notes/form':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
Auth::requireWrite(); $id = (int)($_GET['id'] ?? 0);
$id = (int)($_GET['id'] ?? 0);
$editing = null; // 新建必须能写;看/改已有笔记时**不 requireWrite**(访客也能看共享给他们的)
if ($id <= 0) {
Auth::requireWrite();
}
$note = null;
$perm = '';
if ($id > 0) { if ($id > 0) {
$editing = Note::find($familyId, $id, $me); $note = Note::find($familyId, $id, $me);
if ($editing === null || empty($editing['is_mine'])) { if ($note === null) {
flash('err', '只能编辑自己的笔记。'); flash('err', '这条笔记看不到(可能已删除,或者没有共享给你)。');
redirect('notes'); 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', [ View::render('notes/form', [
'title' => $editing ? '改笔记' : '写笔记', 'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : '看笔记',
'editing' => $editing, 'editing' => $note,
'editMode' => $editMode,
'perm' => $perm,
'canEdit' => $canEdit,
'shares' => $id > 0 ? Note::shares($id) : [],
'tagSuggest' => Note::tagSuggest($familyId, $me), 'tagSuggest' => Note::tagSuggest($familyId, $me),
// 共享要"选人"(v29 第 9 条,与资源页同一个选人组件)
'people' => Person::roster($familyId, $me, 'member', ''),
], 'notes'); ], 'notes');
break; break;
@@ -6407,19 +6471,24 @@ $want = array_keys($want);
} }
} }
$payload = [ $payload = [
'title' => (string)($_POST['title'] ?? ''), 'title' => (string)($_POST['title'] ?? ''),
'blocks' => $blocks, 'blocks' => $blocks,
'tags' => (string)($_POST['tags'] ?? ''), 'tags' => (string)($_POST['tags'] ?? ''),
'is_shared' => !empty($_POST['is_shared']),
]; ];
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
if ($id > 0) { if ($id > 0) {
$done = Note::update($familyId, $id, $me, $payload); $done = Note::update($familyId, $id, $me, $payload);
flash($done ? 'ok' : 'err', $done ? '改好了。' : '只能改自己的笔记。'); flash($done ? 'ok' : 'err', $done ? '改好了。' : '这条笔记你没有写权限。');
redirect('notes'); // 🔴 v29 第 8 条:保存后回到**阅读态**,让用户立刻看到改完的样子
// (而不是停在编辑器里,让人不确定到底存上没存上)。
redirect('notes/form', ['id' => $id]);
} }
$nid = Note::create($familyId, $me, $payload); $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'); redirect('notes');
break; break;
@@ -6429,9 +6498,11 @@ $want = array_keys($want);
Auth::requireWrite(); Auth::requireWrite();
csrf_check(); csrf_check();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$n = Note::find($familyId, $id, $me); $n = Note::find($familyId, $id, $me);
if (!$n || empty($n['is_mine'])) { // 🔴 v29:删除**只限作者本人** —— "可读写"给的是编辑权,不是销毁权
flash('err', '只能删自己的笔记。'); // (与资源的语义一致:写入受限、删除始终归本人/管理员)。
if (!$n || (int)$n['person_id'] !== $me) {
flash('err', '只有作者本人能删这条笔记。');
redirect('notes'); redirect('notes');
} }
Note::destroy($familyId, $id, $me); Note::destroy($familyId, $id, $me);
@@ -6444,6 +6515,49 @@ $want = array_keys($want);
共享出去的笔记别人只能看,不该能往里塞文件。 共享出去的笔记别人只能看,不该能往里塞文件。
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单), ⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
不自己遍历 $_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': case 'notes/attach_add':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
+9 -10
View File
@@ -470,14 +470,13 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」 用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。 ⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
⚠️ 触发条件只看**网格在不在**(`$timed` 非空才有网格),**不要**再加 `$showNow`。 ⚠️ 触发条件:**今天有日程就定位**(只要 `$todayEvents` 非空,页面上就有
第一版写了 `if (!empty($timed) && $showNow)`,后果是: `.ag-grid` 与红线)。曾经写成 `if (!$todayEvents || empty($timed))`,
· 红线落在区间外(现在 23:40、日程只到 18 点 → 页面不画红线)时**整段脚本不渲染** 后果是**今天只有全天日程 / 全天日程都没有分时段的那种日子,脚本整段不输出**
→ "每次进来都要定位"这条需求在最常见的场景(晚上开首页)直接失效; —— 而网格(0–24h)和红线照样渲染,于是"红线在,页面却停在最上面"
· 更糟的是**判据依赖了被优化掉的东西**:探针那次库里没有分时段日程, (v29 用户第 1 条仍然报「没有定位到红线」,实测 `scrollTop=0`、脚本不在页面上,
脚本根本没输出,页面看着"没报错",实测 `scrollTop=0`(本轮实测抓到的)。 就是这个条件卡掉的;有分时段日程时定位其实完全正确、偏差 0px)。
⇒ 现在:**有网格就定位**。红线在就对齐红线;红线不在(在区间外)就用 ⇒ 只判"有没有网格可滚",别判"是不是分时段日程"。
`$nowPos` 同一个比例算出"此刻"在网格里的位置来对齐(数值同源,不会两套口径)。
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`: ⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去), 它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
@@ -486,8 +485,8 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。 ⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 —— ⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?> 两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
<?php if (!$todayEvents || empty($timed)): ?> <?php if (!$todayEvents): ?>
<?php /* 没有分时段日程 → 没有网格可滚,什么都不做 */ ?> <?php /* 今天一条日程都没有 → 页面是「今天没有安排」的空态,没有网格可滚 */ ?>
<?php else: ?> <?php else: ?>
<script> <script>
(function () { (function () {
+106 -15
View File
@@ -55,25 +55,89 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。 用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。
⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**, ⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**,
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */ 否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
$inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== null; /* 🔴🔴 v29 用户第 5 条:「导航的设置,做成设置按钮,点击后,拉下来设置选项,
分别是地址设置、导航设置、板块设置、搜索引擎设置,把现在的设置页面,拆开,
放在不同的设置选项下。」
⇒ 从"一个大的设置页"改成**下拉菜单 + 四个独立视图**:
· `?set=addr` 地址设置(展示页地址 + 短语)
· `?set=general` 导航设置(标题/搜索引擎/布局等,原 SimpleNav 的 settings)
· `?set=board` 板块设置
· `?set=engine` 搜索引擎设置
· 不带 `set` 默认:新增网址 + 网址列表
⚠️ 为什么拆开(而不只是把四块堆在一个?"设置页"里):
用户点的原话就是"拆开"。而且四块各自是**独立表单**,堆在一页里
每次保存后 302 回来都要重新找自己在哪一块。分开之后
"你在哪一块"由 URL 决定,保存后回到原地,位置感清楚。
⚠️ 用**链接**(服务端渲染)而不是 JS 下拉切换内容:
与 v28 的 `?set=1` 同一理由 —— 设置里有表单,保存会 302 回来,
纯 JS 的展开状态在刷新后就丢了。`<details>` 只负责"菜单展开"这一个动效,
点条目是**真实导航**(URL 变、视图变、刷新/回退都不丢)。
⚠️ 编辑态(正在编辑某板块/某引擎)要落到对应的那个视图里,
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
$setKey = (string)($_GET['set'] ?? '');
if ($editGroup !== null) { $setKey = 'board'; }
if ($editEngine !== null) { $setKey = 'engine'; }
$inSettings = in_array($setKey, ['addr', 'general', 'board', 'engine'], true);
/* 设置项的登记表(菜单与"当前在哪一项"的标题都读它,只有一份) */
$setItems = [
'addr' => ['地址设置', 'M2.5 4h11M2.5 8h7M2.5 12h9'],
'general' => ['导航设置', 'M8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z M8 1.5v1.6M8 12.9v1.6M1.5 8h1.6M12.9 8h1.6M3.4 3.4l1.1 1.1M11.5 11.5l1.1 1.1M12.6 3.4l-1.1 1.1M4.5 11.5l-1.1 1.1'],
'board' => ['板块设置', 'M2 3.5h4v4H2zM10 3.5h4v4h-4zM2 9.5h4v4H2zM10 9.5h4v4h-4z'],
'engine' => ['搜索引擎设置', 'M7 2.5a4.5 4.5 0 100 9 4.5 4.5 0 000-9z M10.4 10.4L14 14'],
];
?> ?>
<div class="page-head"> <div class="page-head">
<h1>导航</h1> <h1><?= $inSettings ? e($setItems[$setKey][0]) : '导航' ?></h1>
<span class="sub">你个人用的网址导航 · 每人一份</span> <span class="sub">
<div class="actions">
<?php /* v28 用户第 11 条:右上角「设置」按钮,与其他模块的手指位置一致。
⚠️ 它是个**链接**(服务端切换视图),不是 JS 开关 —— 理由见上面的注释。 */ ?>
<?php if ($inSettings): ?> <?php if ($inSettings): ?>
<a class="btn btn-sm" href="<?= e(url('nav')) ?>">完成</a> <a href="<?= e(url('nav')) ?>">← 回到网址列表</a>
<?php else: ?> <?php else: ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['set' => 1])) ?>">设置</a> 你个人用的网址导航 · 每人一份
<?php endif; ?> <?php endif; ?>
</span>
<div class="actions">
<?php /* 🔴 v29 用户第 5 条:右上角「设置」点开是**下拉菜单**,四项分设。
⚠️ 用项目已有的 `.movie-set.menu`(任务/观影模块同款):
外观、点击外部收起、键盘可达全都是现成的,
这里再手写一套 `<details>` 样式,迟早跟那边不一致。 */ ?>
<details class="movie-set menu">
<summary class="btn btn-ghost btn-sm" title="导航设置" aria-label="导航设置">
设置
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" style="margin-left:2px;vertical-align:-1px">
<path d="M4 6.5l4 4 4-4"/>
</svg>
</summary>
<div class="menu-list">
<?php foreach ($setItems as $k => $it): ?>
<a href="<?= e(url('nav', ['set' => $k])) ?>"<?= $setKey === $k ? ' class="on"' : '' ?>>
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="<?= e($it[1]) ?>"/>
</svg>
<span><?= e($it[0]) ?></span>
</a>
<?php endforeach; ?>
<a href="<?= e(url('nav')) ?>">
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8l3.5 3.5L13 5"/>
</svg>
<span>我的网址</span>
</a>
</div>
</details>
</div> </div>
</div> </div>
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。 <?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?> 只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
<?php if ($inSettings): ?><?php /* ==== 设置视图开始 ==== */ ?> <?php /* ==== 地址设置(?set=addr)==== */ ?>
<?php if ($setKey === 'addr'): ?>
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")==================== <?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似 用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
@@ -117,17 +181,23 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form" <form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form"
onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')"> onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="back" value="addr">
<input type="hidden" name="act" value="phrase_random"> <input type="hidden" name="act" value="phrase_random">
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button> <button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?><?php /* ==== /地址设置 ==== */ ?>
<?php /* ==== 导航设置(?set=general)==== */ ?>
<?php if ($setKey === 'general'): ?>
<?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?> <?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2">导航设置</h2> <h2 class="cinema-h2">导航设置</h2>
<form method="post" action="<?= e(url('nav/save')) ?>"> <form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="back" value="general">
<input type="hidden" name="act" value="settings_save"> <input type="hidden" name="act" value="settings_save">
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
@@ -196,10 +266,15 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
</form> </form>
</div> </div>
<?php endif; ?><?php /* ==== 设置视图(前半:展示地址 + 导航设置)结束 ==== */ ?> <?php endif; ?><?php /* ==== /导航设置 ==== */ ?>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?> <?php /* ==================== ③ 站点:新增/编辑 ==================== */
<?php if ($canWrite): ?> /* 🔴 v29 用户第 5 条:这两块(新增站点 + 站点列表)是**默认视图**的内容,
设置视图(addr/general/board/engine)里不该出现 —— 用户原话是
「进来之后,默认就是添加网址,和所有网址的列表」,反过来说
进了设置就不该再看到"添加网址"。 */
?>
<?php if (!$inSettings && $canWrite): ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2> <h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
<?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了, <?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了,
@@ -276,6 +351,7 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
</div> </div>
<?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?> <?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
<?php if (!$inSettings): ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"> <h2 class="cinema-h2">
站点列表 站点列表
@@ -327,13 +403,24 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?><?php /* ④ 站点列表(仅默认视图) */ ?>
<?php /* ⚠️ 这里必须补上 277 行那个 `if (!$inSettings && $canWrite)` 的收尾 ——
它原本一路开到了文件末尾(607 行),把**板块设置 / 搜索引擎设置**两个视图
也一起包进了"非设置态"里 → 打开 `?set=board` 时整段被跳过,
页面标题显示"板块设置"、内容却是空的(本轮实测抓到的真 bug:
`<!--DBG-->` 探针证明执行根本没走到 409 行)。
⇒ 「非设置态才显示」只该管**站点编辑 + 站点列表**这两块(见 v29 第 5 条),
设置类视图(addr/general/board/engine)各自独立、不受它管。 */ ?>
<?php endif; ?>
<?php if ($inSettings): ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)重新开始 ==== */ ?> <?php /* ==== 板块设置(?set=board)==== */ ?>
<?php if ($setKey === 'board'): ?>
<?php /* ==================== ⑤ 板块 ==================== */ ?> <?php /* ==================== ⑤ 板块 ==================== */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2> <h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>"> <form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="back" value="board">
<input type="hidden" name="act" value="group_save"> <input type="hidden" name="act" value="group_save">
<?php if ($editGroup): ?> <?php if ($editGroup): ?>
<input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>">
@@ -390,11 +477,16 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
</ul> </ul>
</div> </div>
<?php endif; ?><?php /* ==== /板块设置 ==== */ ?>
<?php /* ==== 搜索引擎设置(?set=engine)==== */ ?>
<?php if ($setKey === 'engine'): ?>
<?php /* ==================== ⑥ 搜索引擎 ==================== */ ?> <?php /* ==================== ⑥ 搜索引擎 ==================== */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2> <h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>"> <form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="back" value="engine">
<input type="hidden" name="act" value="engine_save"> <input type="hidden" name="act" value="engine_save">
<?php if ($editEngine): ?> <?php if ($editEngine): ?>
<input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>">
@@ -519,8 +611,7 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?><?php /* ==== /搜索引擎设置 ==== */ ?>
<?php endif; ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)结束 ==== */ ?>
<script> <script>
/* 导航管理页的交互(v24 第 24 批)。 /* 导航管理页的交互(v24 第 24 批)。
+326 -163
View File
@@ -1,40 +1,62 @@
<?php <?php
/** /**
* 个人笔记 · 写 / 改(v27 用户 2026-10-07)。新建与编辑**同一个模板**。 * 个人笔记 · 一条笔记的两种面貌(v29 用户 2026-10-08 第 6/7/8/9 条)。
* *
* 「分块」的落点就在这一页:正文区由若干**块**组成,每块是 * 🔴 用户原话(第 8 条):
* 段落 / 图片 / 清单 三者之一,可增、可删、可上下移。 * 「笔记点进来,默认是阅读状态,图文混排,附件在最下方,图文混排时,
* 需要可以调整大小。点击编辑按钮,才进入编辑功能。
* 清单功能不要了吧,markdown 里面包含这个功能,把段落做的完善一点就可以了。」
* ⇒ 这个模板现在有**两个态**:
* · **阅读态**(`$editMode = false`):图文按顺序排下来、附件在最底下、
* 右上角一个「编辑」按钮。多数时候用户是来看的。
* · **编辑态**(`$editMode = true`):分块编辑器 + 工具条 + Markdown 补全。
* *
* 三条刻意的轻量取舍(写清楚,免得后人"顺手升级"成重型编辑器): * 🔴 用户原话(第 7 条):
* ① **不做富文本工具栏**(粗体/斜体/颜色…)。本项目全站的文本都是"纯文本 + * 「添加段落部分,还是不太好,这样就意味着用户还是要记住所有的 markdown 命令,
* 换行",只有这里破例就会出现两套文本口径(导出、搜索、复制都会不一致)。 * 要么有工具条,要么有代码提示,例如插入一个斜线,然后输入代码,例如 code,
* 需要强调就写符号,需要排版就用清单块。 * 就能自动补全 code 的 markdown 代码。」
* ② **不做 Markdown 预览**。可见即所得:段落块就是段落,清单块就是清单 —— * ⇒ **两样都给**(用户原话就是"要么…要么…",那两样都给最省事):
* 用户不必再学"我打的东西会变成什么"。这也是"轻量"最实在的一层。 * · 工具条:粗体 / 斜体 / 标题 / 列表 / 引用 / 代码 / 链接 —— 点一下插到光标处;
* ③ **不做拖拽排序**,只有 ↑ ↓ 两个按钮。鼠标拖拽要引入一整套 pointer 事件 * · 斜线提示:行首打 `/` 弹出命令菜单,打 `code` 回车就补上代码块。
* 与触摸兼容(本仓名册拖拽就为它写了上千行),而笔记的块通常只有几块,
* 两个按钮足够,代码少一个数量级。
* *
* ⚠️ 提交形状:所有块打包成一个 JSON 放进隐藏域 `blocks`(见页尾脚本)。 * ⚠️ 为什么"阅读态"要单独一份渲染(而不是复用编辑器的预览):
* 为什么不是 `kind[]` / `content[]` 并行数组:服务端要按下标对齐, * 编辑器里的预览是"每块一个方框、带虚线边框",那是**编辑**的视觉语言;
* 一旦前端少发一个(比如空块被过滤)整个错位,而且很难在页面上看出来。 * 阅读态要的是干净的排版(没有框、没有按钮)。两者混用会让阅读页
* JSON 一把梭,服务端 `json_decode` 之后逐块清洗,简单且不会错位。 * 看着像"还没保存的草稿"。
* ⚠️ **无 JS 兜底**:`<noscript>` 里给一个普通 textarea(`body_plain`), * ⚠️ 图片"可以调整大小"(第 8 条):用 width 百分比存进图片块的内容里
* 服务端在 `blocks` 为空时会退回读它(见 index.php 的 notes/save)。 * (`key|50`),渲染时按它出宽度。见下面 `$imgW` 的解析与注释。
* *
* @param array|null $editing 编辑时的笔记(带 blocks/tags/is_shared);新建为 null * @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 我用过的标签(点一下就加) * @param array $tagSuggest 我用过的标签(点一下就加)
*/ */
$editing = $editing ?? null; $editing = $editing ?? null;
$isEditing = $editing !== null; $isEditing = $editing !== null;
$editMode = (bool)($editMode ?? ($isEditing ? false : true));
$canWrite = \App\Auth::canWrite(); $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 = []; $blocks = [];
$tagsStr = ''; $tagsStr = '';
if ($isEditing) { if ($isEditing) {
foreach ((array)$editing['blocks'] as $b) { foreach ((array)$editing['blocks'] as $b) {
$blocks[] = ['kind' => (string)$b['kind'], 'content' => (string)$b['content']]; // ⚠️ 历史 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'])); $tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
} }
@@ -42,126 +64,86 @@ if (!$blocks) {
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写) // 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
$blocks = [['kind' => 'text', 'content' => '']]; $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"> <div class="page-head">
<h1><?= $isEditing ? '改笔记' : '写笔记' ?></h1> <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"> <div class="actions">
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">返回列表</a> <?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>
</div> </div>
<?php if (!$canWrite): ?> <?php if (!$isEditing && !$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div> <div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
<?php else: ?> <?php elseif (!$editMode): ?>
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form"> <?php /* ==================== ✅ 阅读态(默认)====================
<?= csrf_field() ?> 用户第 8 条要的"图文混排 + 附件在最下方",就在这一段。
<?php if ($isEditing): ?> ⚠️ 图片块用 `<figure>` 包:宽度可调(第 8 条),且要与正文有间距。
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>"> ⚠️ 附件(`$editing['files']`)**排在正文之后、页面最下方** ——
<?php endif; ?> 用户原话「附件在最下方」。 */ ?>
<?php /* 块的真实载荷(由页尾脚本维护) */ ?> <article class="note-read card card-pad">
<input type="hidden" name="blocks" id="noteBlocks" value=""> <?php $title = trim((string)($editing['title'] ?? '')); ?>
<?php if ($title !== ''): ?>
<h1 class="note-read-title"><?= e($title) ?></h1>
<?php endif; ?>
<div class="card card-pad"> <?php if (!empty($editing['tags'])): ?>
<div class="field"> <div class="note-tags note-read-tags">
<label for="noteTitle">标题</label> <?php foreach ((array)$editing['tags'] as $t): ?>
<input class="input" id="noteTitle" name="title" maxlength="200" <a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">#<?= e((string)$t) ?></a>
value="<?= e((string)($editing['title'] ?? '')) ?>" <?php endforeach; ?>
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>
<button class="btn btn-ghost btn-sm" type="button" data-add="list">+ 清单</button>
</div> </div>
</div> <?php endif; ?>
<?php /* 🔴 v28 用户第 12 条:「段落是需要支持富文本的,可以使用 markdown, <div class="note-read-body">
但是需要使用富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。 <?php foreach ((array)$editing['blocks'] as $b): ?>
就是说,允许所见即所得编辑,也允许 markdown 编辑。」 <?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>
写 Markdown 的同时就能看到它长什么样,不必切来切去;
② 一个**全局"源码 / 预览"开关**:想专心改 Markdown 源码时,
把实时预览关掉(长文档下预览会占很多地方,也会让人分心)。
⚠️ 为什么不做 contenteditable 那种"直接在渲染结果上编辑":
那要引入一套 HTML↔Markdown 的双向转换与清洗(正是"引大库"的那条路),
而且富文本内容再存回 Markdown 时几乎必然丢格式或引入噪音。
这里选择"**输入 Markdown + 实时预览**"—— 两个诉求都满足,
数据始终是干净的 Markdown 纯文本(可导出、可搜索、可版本对比)。 */ ?>
<div class="note-modebar">
<span class="small muted">段落支持 Markdown:<code>**粗体**</code>
<code>*斜体*</code> <code># 标题</code> <code>- 清单</code> <code>&gt; 引用</code>
<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; ?> <?php endif; ?>
</div> </div>
<label class="note-share-toggle"> <?php /* ---- 附件:永远在最后 ---- */ ?>
<input type="checkbox" name="is_shared" value="1" <?php if (!empty($editing['files'])): ?>
<?= !empty($editing['is_shared']) ? 'checked' : '' ?>> <section class="note-read-files">
<span>共享给全家(默认只有自己看得到)</span> <h2 class="note-read-h2">附件</h2>
</label>
</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 /* 已有附件:可下载、可删 */ ?>
<?php if (!empty($editing['files'])): ?>
<div class="note-files"> <div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?> <?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?> <?php $fk = \App\Attachment::kind($f); ?>
@@ -171,55 +153,236 @@ if (!$blocks) {
<?= e((string)($f['name'] ?? '附件')) ?> <?= e((string)($f['name'] ?? '附件')) ?>
</a> </a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span> <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> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php else: ?> </section>
<p class="empty-hint" id="noteFilesEmpty">还没有附件。</p> <?php endif; ?>
<?php endif; ?> </article>
<form method="post" enctype="multipart/form-data" <?php /* ==================== 共享(v29 第 9 条:选人 + 权限,同资源)====================
action="<?= e(url('notes/attach_add')) ?>" id="noteAttForm"> 🔴 只在**作者**视角显示:共享范围是作者的决定,拿到只读的人不该看到名单。
交互与资源页完全一致:选人 → 选权限 → 共享;已共享的逐个可「取消共享」。 */ ?>
<?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() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop"> <div class="field">
<input type="file" name="file[]" id="noteAttInput" multiple <label>共享给谁</label>
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx"> <?php
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span> $ppName = 'person_id';
$ppPeople = $people ?? [];
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">你自己本来就有全部权限,不用(也不能)共享给自己。</span>
</div> </div>
<div class="btn-row"> <div class="field">
<button class="btn btn-ghost btn-sm" type="submit">上传所选文件</button> <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> </div>
</form> </form>
</div>
<?php else: ?> <div class="note-share-now">
<div class="card card-pad"> <h3 class="note-read-h2">已共享给</h3>
<h2 class="cinema-h2">附件</h2> <?php if ($shares): ?>
<p class="empty-hint">先「记下来」,保存之后回到这一页就能传附件了。</p> <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> </div>
<?php endif; ?> <?php endif; ?>
<div class="note-actions"> <?php else: ?>
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button> <?php /* ==================== ✏️ 编辑态 ==================== */ ?>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a> <form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
</div> <?php /* ⚠️ media 地址**不在表单上注入**:`media_url('')` 返回的是默认头像
</form> (见 helpers.php 里 `$key === ''` 那一支),拿它当基址会拼出
`avatar-default.svgabc123` 这种地址。基址统一由 layout 的
<?php if ($isEditing): ?> `<body data-media-base="media.php?k=">` 提供,脚本读它就行。 */ ?>
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
避免"每行一份 form"那种页面膨胀)。 */ ?>
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value=""> <?php if ($isEditing): ?>
</form> <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?> <?php endif; ?>
<?php /* 块的真实载荷(由页尾脚本维护) */ ?>
<input type="hidden" name="blocks" id="noteBlocks" value="">
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* —— <div class="card card-pad">
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> <div class="field">
<script type="application/json" id="noteSeed"><?= json_encode( <label for="noteTitle">标题</label>
$blocks, <input class="input" id="noteTitle" name="title" maxlength="200"
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP value="<?= e((string)($editing['title'] ?? '')) ?>"
) ?></script> 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; ?> <?php endif; ?>
+20 -5
View File
@@ -93,7 +93,9 @@ $canWrite = \App\Auth::canWrite();
$firstImg = ''; $firstImg = '';
foreach ((array)$n['blocks'] as $b) { foreach ((array)$n['blocks'] as $b) {
if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') { 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; break;
} }
} }
@@ -101,8 +103,14 @@ $canWrite = \App\Auth::canWrite();
<article class="note-card" data-note="<?= $nid ?>"> <article class="note-card" data-note="<?= $nid ?>">
<header class="note-head"> <header class="note-head">
<span class="note-meta"> <span class="note-meta">
<?php if (!empty($n['is_shared'])): ?> <?php
<span class="note-share" title="全家可见">共享</span> /* 🔴 v29 第 9 条:共享状态从"共享/私有"两态改成**按人数**显示 ——
共享已升级成"逐人 + 权限",只说"共享"已经说不清共享给了几个人。
$shareN 是路由一次查好的映射(别在循环里再查库)。 */
$sN = (int)($shareN[$nid] ?? 0);
?>
<?php if ($sN > 0): ?>
<span class="note-share" title="共享给 <?= $sN ?> 个人">共享 <?= $sN ?></span>
<?php else: ?> <?php else: ?>
<span class="note-private" title="只有自己可见">私有</span> <span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?> <?php endif; ?>
@@ -110,7 +118,7 @@ $canWrite = \App\Auth::canWrite();
</span> </span>
<?php if ($mine && $canWrite): ?> <?php if ($mine && $canWrite): ?>
<span class="note-ops"> <span class="note-ops">
<a class="note-op" href="<?= e(url('notes/form', ['id' => $nid])) ?>" <a class="note-op" href="<?= e(url('notes/form', ['id' => $nid, 'edit' => 1])) ?>"
title="编辑这条笔记">改</a> title="编辑这条笔记">改</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form" <form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');"> onsubmit="return confirm('删掉这条笔记?');">
@@ -122,6 +130,7 @@ $canWrite = \App\Auth::canWrite();
<?php endif; ?> <?php endif; ?>
</header> </header>
<?php /* 🔴 v29 第 8 条:卡片点进去是**阅读态**(不是编辑器) */ ?>
<a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>"> <a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
<?php if ($title !== ''): ?> <?php if ($title !== ''): ?>
<h3 class="note-title"><?= e($title) ?></h3> <h3 class="note-title"><?= e($title) ?></h3>
@@ -133,7 +142,13 @@ $canWrite = \App\Auth::canWrite();
<?php endif; ?> <?php endif; ?>
</a> </a>
<?php /* 图片:等比缩放到卡片宽度,放在摘要下方(用户第 12 条原话) */ ?> <?php /* 🔴 v29 第 6 条:「笔记,首页显示的时候,里面的图片,和行程的类似,
把图片按照比例缩放到固定的高度或者宽度,放在下面就行了。」
⇒ 卡片下方的缩略图改成**固定高度 + 等比缩放**(`object-fit: cover`
会裁切,用户看到的不再是完整的那张图;这里要的是"完整 + 整齐")。
⇒ 用 `object-fit: contain` + 固定高度:图片**完整**显示、
高度**整齐划一**,宽的那张两侧留白(比裁掉内容好,也比卡片高矮不一好)。
对比"行程"那边的做法(用户说"和行程的类似"):同一套观感。 */ ?>
<?php if ($firstImg !== ''): ?> <?php if ($firstImg !== ''): ?>
<div class="note-thumb"> <div class="note-thumb">
<img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async"> <img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
+12 -1
View File
@@ -211,7 +211,18 @@ $renderGrid = function (array $list) use ($meId, $canMove): void {
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>" data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button> aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?> <?php endif; ?>
</div> </div><?php /* /.person-actions */ ?>
</div><?php /* /.person
🔴🔴 v29 第 3 条「名册页面完全错乱」的**真根因**就在这里:
这一行 `</div>`(闭合 `.person`)在 v29 重写平铺列表时**漏掉了**。
浏览器遇到"卡片没闭合"会**把下一张卡塞进上一张里**(DOM 修复行为),
于是每张卡的宽度被前一张的按钮吃掉 —— 实测第一张的 `.person-main`
只剩 24~151px(本该 ~800px),姓名与徽标全被挤碎,
整页看起来就是"错乱"。
⚠️ 这类"少一个闭合标签"的 bug,`php -l` 与静态断言**全绿**
(PHP 语法没错、PHP 的 if/endif 也平衡),只有真机量 DOM 才现形。
定位手法:量 `.person` 的 `children`,发现里面**有另一个 `.person`**
—— 嵌套的同类元素 = 上一张没收尾。 */ ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php <?php