From 404b1afee080e5674805feb694c8d01298c949fb Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 7 Oct 2026 18:22:55 +0800 Subject: [PATCH] =?UTF-8?q?v27=20=E4=B8=AA=E4=BA=BA=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=9A=E5=88=86=E5=9D=97=EF=BC=88=E6=AE=B5?= =?UTF-8?q?=E8=90=BD/=E5=9B=BE=E7=89=87/=E6=B8=85=E5=8D=95=EF=BC=89+=20?= =?UTF-8?q?=E5=8D=95=E5=B1=82=E5=A4=9A=E6=A0=87=E7=AD=BE=20+=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=A7=81=E6=9C=89=E5=8F=AF=E5=85=B1=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 flomo(时间流卡片 + 标签即筛)、Obsidian(图文混排的直觉)、 思源(「块」的粒度),但刻意只取轻的那一层: - 分块只做 text / image / list 三种,可增删、可上下移;不做块引用 / 块 ID 双链 - 标签单层、多对多,只存字符串(**不建标签表**)——改名/合并这类重型能力一律不做 - 不做富文本工具栏、不做 Markdown 预览、不做拖拽排序(只有 ↑↓ 按钮) - 可见性默认**私有**,单条可勾「共享给全家」;共享出去只给别人看,不给别人改 落地: - 新增 app/Note.php(块清洗、标签归一、可见性、认领草稿附件) - 新增 3 张表 notes / note_blocks / note_tags;Schema VERSION 26→27 - 🔴 attachments.target_type 是 ENUM:加 'note' 必须同时改建表串 + 新增 widenAttachTargetEnum()(老库不做这一步,传笔记图片会被严格模式拒掉) - 路由 5 条:notes / notes/form / notes/save / notes/destroy / notes/upload - 模板 templates/notes/index.php(列表)+ form.php(分块编辑) - JS assets/js/note_editor.js(按需加载,同 person_picker 那套) - 编辑器状态放 DOM 而非 JS 数组:移动块时用户已输入的内容一字不丢 --- app/Module.php | 5 +- app/Note.php | 520 ++++++++++++++++++++++++++++++++++++++ app/Schema.php | 86 ++++++- assets/css/app.css | 106 ++++++++ assets/js/note_editor.js | 243 ++++++++++++++++++ index.php | 154 +++++++++++ templates/layout.php | 13 + templates/notes/form.php | 140 ++++++++++ templates/notes/index.php | 168 ++++++++++++ 9 files changed, 1432 insertions(+), 3 deletions(-) create mode 100644 app/Note.php create mode 100644 assets/js/note_editor.js create mode 100644 templates/notes/form.php create mode 100644 templates/notes/index.php diff --git a/app/Module.php b/app/Module.php index 28344d2..7715d45 100644 --- a/app/Module.php +++ b/app/Module.php @@ -51,6 +51,9 @@ final class Module // 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以 // 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。 'nav' => ['导航', '#3E7A9E', 'nav'], + // v27 用户 2026-10-07:个人笔记(参考 flomo / Obsidian / 思源,轻量分块 + 标签)。 + // 放在**导航后面**(末尾)—— 它是"随手记",不该抢日常入口的位置。 + 'notes' => ['笔记', '#7A6A4F', 'notes'], ]; /** @@ -61,7 +64,7 @@ final class Module * 只是不再占顶栏的位置。 */ public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', - 'tasks', 'days', 'ledger', 'books', 'nav']; + 'tasks', 'days', 'ledger', 'books', 'nav', 'notes']; /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24'; diff --git a/app/Note.php b/app/Note.php new file mode 100644 index 0000000..d690d2a --- /dev/null +++ b/app/Note.php @@ -0,0 +1,520 @@ + 每行带 blocks(已按 position 排好)与 tags + */ + public static function all( + int $familyId, + int $viewer, + string $keyword = '', + string $tag = '', + int $limit = 0, + int $offset = 0 + ): array { + $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 + FROM notes n + JOIN persons p ON p.id = n.person_id + WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; + $bind = [$viewer, $familyId, $viewer]; + + $kw = trim($keyword); + if ($kw !== '') { + // 正文在块里 —— 用 EXISTS 子查询搜块,避免 JOIN 出重复行 + $sql .= ' AND (n.title LIKE ? + OR EXISTS (SELECT 1 FROM note_blocks b + WHERE b.note_id = n.id AND b.content LIKE ?) + OR EXISTS (SELECT 1 FROM note_tags t + WHERE t.note_id = n.id AND t.tag LIKE ?))'; + $like = '%' . $kw . '%'; + array_push($bind, $like, $like, $like); + } + $tg = trim($tag); + if ($tg !== '') { + $sql .= ' AND EXISTS (SELECT 1 FROM note_tags t WHERE t.note_id = n.id AND t.tag = ?)'; + $bind[] = $tg; + } + + // ⚠️ 按「更新时间」倒序:笔记是"最近记的/最近改的"才要优先看(同 flomo 的时间流)。 + $sql .= ' ORDER BY n.updated_at DESC, n.id DESC'; + if ($limit > 0) { + $sql .= ' LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; + } + $st = Database::get()->prepare($sql); + $st->execute($bind); + $rows = $st->fetchAll(); + if (!$rows) { + return []; + } + return self::attachChildren($rows); + } + + /** 与 all() 同一套可见性条件,只为列表页说清"一共几条" */ + public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int + { + $sql = 'SELECT COUNT(*) FROM notes n + WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'; + $bind = [$familyId, $viewer]; + $kw = trim($keyword); + if ($kw !== '') { + $sql .= ' AND (n.title LIKE ? + OR EXISTS (SELECT 1 FROM note_blocks b + WHERE b.note_id = n.id AND b.content LIKE ?) + OR EXISTS (SELECT 1 FROM note_tags t + WHERE t.note_id = n.id AND t.tag LIKE ?))'; + $like = '%' . $kw . '%'; + array_push($bind, $like, $like, $like); + } + $tg = trim($tag); + if ($tg !== '') { + $sql .= ' AND EXISTS (SELECT 1 FROM note_tags t WHERE t.note_id = n.id AND t.tag = ?)'; + $bind[] = $tg; + } + $st = Database::get()->prepare($sql); + $st->execute($bind); + return (int)$st->fetchColumn(); + } + + /** + * 一次把 blocks / tags 挂到结果集上。 + * + * ⚠️ 为什么不用 JOIN 一把梭:一条笔记有多块多标签,JOIN 出来是笛卡尔积, + * 还要在 PHP 里去重。分两次 IN 查询(块、标签各一次)反而是**两条 SQL + * 解决整页**,也不会有 N+1。 + */ + private static function attachChildren(array $rows): array + { + $ids = array_map(static fn($r): int => (int)$r['id'], $rows); + $in = implode(',', array_map('intval', $ids)); + + $blocks = []; + $st = Database::get()->query( + "SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id" + ); + foreach ($st->fetchAll() as $b) { + $blocks[(int)$b['note_id']][] = $b; + } + + $tags = []; + $st = Database::get()->query( + "SELECT note_id, tag FROM note_tags WHERE note_id IN ($in) ORDER BY note_id, id" + ); + foreach ($st->fetchAll() as $t) { + $tags[(int)$t['note_id']][] = (string)$t['tag']; + } + + foreach ($rows as &$r) { + $id = (int)$r['id']; + $r['blocks'] = $blocks[$id] ?? []; + $r['tags'] = $tags[$id] ?? []; + } + unset($r); + return $rows; + } + + /** 单条(带权限判定)。看不到返回 null。 */ + public static function find(int $familyId, int $id, int $viewer): ?array + { + $st = Database::get()->prepare( + 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color + 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)' + ); + $st->execute([$id, $familyId, $viewer]); + $r = $st->fetch(); + if (!$r) { + return null; + } + $r['blocks'] = self::blocks($id); + $r['tags'] = self::tags($id); + $r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0; + return $r; + } + + /** 某条笔记的块(按位置) */ + public static function blocks(int $noteId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id' + ); + $st->execute([$noteId]); + return $st->fetchAll(); + } + + /** 某条笔记的标签 */ + public static function tags(int $noteId): array + { + $st = Database::get()->prepare('SELECT tag FROM note_tags WHERE note_id = ? ORDER BY id'); + $st->execute([$noteId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[] = (string)$r['tag']; + } + return $out; + } + + /** + * 我用过的全部标签 + 各自多少条(给侧边标签云用)。 + * + * ⚠️ 统计范围 = **我能看到的**笔记(不带 tag 过滤),否则会出现 + * "标签上写着 8 条,点进去只有 3 条"这种自相矛盾。 + */ + public static function tagCloud(int $familyId, int $viewer): array + { + $st = Database::get()->prepare( + 'SELECT t.tag, COUNT(DISTINCT n.id) AS n + 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) + GROUP BY t.tag ORDER BY n DESC, t.tag ASC' + ); + $st->execute([$familyId, $viewer]); + return $st->fetchAll(); + } + + /** 写新笔记时给个标签联想:所有我用过的标签词(按人气) */ + public static function tagSuggest(int $familyId, int $viewer, int $limit = 40): array + { + $rows = self::tagCloud($familyId, $viewer); + $out = []; + foreach ($rows as $r) { + $out[] = (string)$r['tag']; + if (count($out) >= $limit) { + break; + } + } + return $out; + } + + // ===================================================================== + // 写 + // ===================================================================== + + /** + * 新建一条笔记。 + * + * @param array $d title / blocks([{kind,content}]) / tags([string]) / is_shared + * @return int 新笔记 id(0 = 标题与正文都空,没建) + */ + public static function create(int $familyId, int $personId, array $d): int + { + $title = trim((string)($d['title'] ?? '')); + $blocks = self::cleanBlocks($d['blocks'] ?? []); + // 标题和正文都空 = 用户什么都没写 → 不建空壳(否则列表里会冒出白卡片) + if ($title === '' && !$blocks) { + return 0; + } + $db = Database::get(); + $db->prepare( + 'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at) + VALUES (?,?,?,?,NOW(),NOW())' + )->execute([ + $familyId, $personId, + mb_substr($title, 0, 200, 'UTF-8'), + !empty($d['is_shared']) ? 1 : 0, + ]); + $id = (int)$db->lastInsertId(); + self::writeBlocks($id, $blocks); + self::replaceTags($id, $d['tags'] ?? []); + // 把这次上传的"草稿附件"(target_id=0)认领到新笔记上 + self::claimUploads($familyId, $id, $blocks); + return $id; + } + + /** + * 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。 + * @return bool 是否改到了(改不到 = 不是我的/不存在) + */ + public static function update(int $familyId, int $id, int $personId, array $d): bool + { + $db = Database::get(); + $st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?'); + $st->execute([$id, $familyId, $personId]); + if (!$st->fetchColumn()) { + return false; + } + $title = trim((string)($d['title'] ?? '')); + $blocks = self::cleanBlocks($d['blocks'] ?? []); + // 改完标题正文全空 = 用户把内容清光了。**不删笔记**(用户可能只是暂时清空), + // 但也不让它变成一条列表里点开什么都没有的白卡片 —— 给个占位标题。 + if ($title === '' && !$blocks) { + $title = '(空笔记)'; + } + $db->prepare( + '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]); + // 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值, + // 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。 + self::writeBlocks($id, $blocks); + self::replaceTags($id, $d['tags'] ?? []); + self::claimUploads($familyId, $id, $blocks); + return true; + } + + /** + * 把本次上传的图片块附件从"草稿态"认领到这条笔记上。 + * + * 上传发生在写笔记的过程中(点"加图片"就传了),那时笔记还不存在, + * 所以附件先挂在 `target_type='note', target_id=0`。这里按图片块的 key + * 把它们改挂到真实 note_id 上。 + * + * ⚠️ 用**本次提交里出现的 key** 来认领,而不是"把 target_id=0 的全部认领" —— + * 后者会把别人(或本人在别处还没保存)的草稿一起抢过来。 + * ⚠️ 认领完顺手清掉**本家庭**剩下的孤儿草稿(target_id=0 且超过 1 天): + * 用户点了加图又放弃保存,就会留下一张谁也看不到的图。 + * 按家庭 + 时间双重限定,不会误删"此刻正在编辑中"的那些。 + */ + private static function claimUploads(int $familyId, int $noteId, array $blocks): void + { + $keys = []; + foreach ($blocks as $b) { + if ($b['kind'] === 'image' && $b['content'] !== '') { + $keys[] = $b['content']; + } + } + $db = Database::get(); + if ($keys) { + $in = implode(',', array_fill(0, count($keys), '?')); + $args = $keys; + $args[] = $familyId; + $db->prepare( + "UPDATE attachments SET target_id = ? + WHERE target_type = 'note' AND target_id = 0 + AND `key` IN ($in) AND family_id = ?" + )->execute(array_merge([$noteId], $args)); + } + // 清孤儿草稿(只在认领时顺手做,不必每次请求都扫) + $db->prepare( + "DELETE FROM attachments + WHERE target_type = 'note' AND target_id = 0 AND family_id = ? + AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)" + )->execute([$familyId]); + } + + /** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */ + public static function destroy(int $familyId, int $id, int $personId): void + { + Database::get()->prepare( + 'DELETE FROM notes WHERE id = ? AND family_id = ? AND person_id = ?' + )->execute([$id, $familyId, $personId]); + } + + /** + * 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。 + * 图片块显示成 `[图片]`,清单块显示成 `· 首项`。 + */ + public static function excerpt(array $note, int $len = 120): string + { + $buf = []; + foreach ((array)($note['blocks'] ?? []) as $b) { + $kind = (string)$b['kind']; + $c = trim((string)$b['content']); + if ($kind === 'image') { + $buf[] = '[图片]'; + } elseif ($kind === 'list') { + foreach (preg_split('/\R/u', $c) ?: [] as $line) { + $line = trim($line); + if ($line !== '') { + $buf[] = '· ' . $line; + } + } + } elseif ($c !== '') { + $buf[] = $c; + } + } + $txt = preg_replace('/\s+/u', ' ', implode(' ', $buf)) ?? ''; + $txt = trim($txt); + if (mb_strlen($txt, 'UTF-8') > $len) { + $txt = mb_substr($txt, 0, $len, 'UTF-8') . '…'; + } + return $txt; + } + + // ===================================================================== + // 内部:块 / 标签的清洗与写入 + // ===================================================================== + + /** + * 清洗前端提交的块: + * · kind 归一(只认白名单里的三种,其它一律当 text) + * · 内容裁剪(去首尾空白;图片块 content = 附件 key) + * · 丢掉**空块**(用户加了块又没填,不该留一堆空段) + * · 上限 MAX_BLOCKS + * + * @return array + */ + private static function cleanBlocks($raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $b) { + if (!is_array($b)) { + continue; + } + $kind = (string)($b['kind'] ?? 'text'); + if (!in_array($kind, self::BLOCK_KINDS, true)) { + $kind = 'text'; + } + $content = (string)($b['content'] ?? ''); + // 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查) + $content = $kind === 'image' + ? trim(preg_replace('/\s+/', '', $content) ?? '') + : rtrim(ltrim($content, "\r\n\t "), "\r\n\t "); + if ($content === '') { + continue; // 空块丢掉 + } + if ($kind !== 'image') { + $content = mb_substr($content, 0, self::MAX_TEXT, 'UTF-8'); + } + $out[] = ['kind' => $kind, 'content' => $content]; + if (count($out) >= self::MAX_BLOCKS) { + break; + } + } + return $out; + } + + private static function writeBlocks(int $noteId, array $blocks): void + { + $db = Database::get(); + $db->prepare('DELETE FROM note_blocks WHERE note_id = ?')->execute([$noteId]); + if (!$blocks) { + return; + } + $st = $db->prepare( + 'INSERT INTO note_blocks (note_id, kind, content, position) VALUES (?,?,?,?)' + ); + $pos = 0; + foreach ($blocks as $b) { + $st->execute([$noteId, $b['kind'], $b['content'], $pos]); + $pos += 10; + } + } + + /** + * 标签归一:`#读书` / `# 读书` / `读书 ` 都算「读书」;去重;单层。 + * + * ⚠️ 为什么**不建 tags 表**(只有一个 note_tags 存字符串): + * 本模块的标签是"我随手打的词",不是需要改名/合并/配色的实体。 + * 建一张标签表就得处理"改名后历史笔记怎么办""两个标签合并"—— + * 那是重型知识库(思源那类)才值得的复杂度,与"轻量级"直接冲突。 + * 用字符串存,标签云靠 GROUP BY 现算,几十上百条笔记毫无压力。 + */ + private static function replaceTags(int $noteId, $raw): void + { + $db = Database::get(); + $db->prepare('DELETE FROM note_tags WHERE note_id = ?')->execute([$noteId]); + $tags = self::cleanTags($raw); + if (!$tags) { + return; + } + $st = $db->prepare('INSERT INTO note_tags (note_id, tag) VALUES (?,?)'); + foreach ($tags as $t) { + $st->execute([$noteId, $t]); + } + } + + /** + * @param string|array $raw 数组,或一段带 `#标签` 的文本 + * @return array 去重后的标签(最多 12 个,每个 ≤ 20 字) + */ + public static function cleanTags($raw): array + { + $list = []; + if (is_array($raw)) { + $list = $raw; + } elseif (is_string($raw)) { + // 支持"从正文里抓 #标签"(flomo 那种随手写法) + $txt = $raw; + if (preg_match_all('/(?:^|\s)#([^\s#]{1,20})/u', $txt, $m)) { + $list = $m[1]; + } else { + $list = preg_split('/[\s,,]+/u', $txt) ?: []; + } + } + $out = []; + foreach ($list as $t) { + $t = trim((string)$t); + $t = ltrim($t, '#'); + $t = trim($t); + // 空白与 `#` 都不允许出现在标签内部(它俩是分隔符) + $t = (string)preg_replace('/[\s#]+/u', '', $t); + if ($t === '') { + continue; + } + $t = mb_substr($t, 0, 20, 'UTF-8'); + if (!in_array($t, $out, true)) { + $out[] = $t; + } + if (count($out) >= 12) { + break; + } + } + return $out; + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index c9ad4bc..cb2ceda 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -97,6 +97,10 @@ final class Schema * 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了 * (所有设备上的同步立刻 401)。而且必须先判旧表在不在: * 全新安装时没有旧表,直接 RENAME 会报错把整段升级打断。 + * 27 → **个人笔记(用户 2026-10-07)**:`notes`(卡片)+ `note_blocks`(卡内的块) + * + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**: + * 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表; + * 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -104,7 +108,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '26'; + public const VERSION = '27'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -448,7 +452,7 @@ SQL, CREATE TABLE IF NOT EXISTS `attachments` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `family_id` INT UNSIGNED NOT NULL, - `target_type` ENUM('trip','trip_node','resource','task','task_comment') NOT NULL COMMENT '挂在谁身上', + `target_type` ENUM('trip','trip_node','resource','task','task_comment','note') NOT NULL COMMENT '挂在谁身上', `target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id', `key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)', `name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)', @@ -915,6 +919,57 @@ CREATE TABLE IF NOT EXISTS `nav_settings` ( PRIMARY KEY (`person_id`, `k`), CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)' +SQL, + // ---- 个人笔记(v27,用户 2026-10-07)---- + // 三张表都很薄。为什么这样切(详见 App\Note 头部): + // · notes —— 卡片(标题 + 可见性 + 归属人) + // · note_blocks—— 卡内的块(text/image/list + 排序)「分块」的落点 + // · note_tags —— 标签(单层,存字符串,**不建标签表**) + // ⚠️ 外键一律 `int(10) unsigned` 与 persons.id / families.id 严格同型 —— + // 类型不一致(比如写成 INT 而非 INT UNSIGNED)会 errno 150 建表失败, + // 而报错信息只说"外键约束不对",很难一眼看出是类型问题(本仓踩过)。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `notes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '作者(笔记属于人,不属于家)', + `title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题(可空,纯正文笔记也允许)', + `is_shared` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '共享给全家?0=私有(默认) 1=全家可见', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '改过就刷新(列表按它倒序)', + PRIMARY KEY (`id`), + KEY `idx_note_owner` (`family_id`, `person_id`, `updated_at`), + KEY `idx_note_shared` (`family_id`, `is_shared`, `updated_at`), + CONSTRAINT `fk_note_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_note_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='个人笔记:一条 = 一张卡片(v27 用户 2026-10-07)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `note_blocks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `note_id` INT UNSIGNED NOT NULL, + `kind` VARCHAR(16) NOT NULL DEFAULT 'text' + COMMENT '块类型:text=段落 / image=图片 / list=清单', + `content` TEXT NOT NULL COMMENT 'text/list=正文;image=附件 key(走 media.php)', + `position` INT NOT NULL DEFAULT 10 COMMENT '卡内顺序(步长 10,留插入空间)', + PRIMARY KEY (`id`), + KEY `idx_nb_note` (`note_id`, `position`), + CONSTRAINT `fk_nb_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='笔记的块:卡片内的一段/一张图/一个清单(v27「分块」)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `note_tags` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `note_id` INT UNSIGNED NOT NULL, + `tag` VARCHAR(20) NOT NULL COMMENT '单层标签词(不含 #;同一笔记内不重复)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_nt` (`note_id`, `tag`), + KEY `idx_nt_tag` (`tag`), + CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)' SQL, ]; } @@ -1331,6 +1386,11 @@ SQL); // 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改, // 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。 self::widenEventKindEnum($db); + // v27:attachments.target_type 要能挂笔记的图片('note')。 + // ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表** + // 不会更新 —— 老库必须显式 MODIFY,否则上传笔记图片时插入 + // target_type='note' 会被严格模式整条拒掉(表现为"图传不上去")。 + self::widenAttachTargetEnum($db); // 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在 // 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。 self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) { @@ -2118,6 +2178,28 @@ SQL); } } + /** + * attachments.target_type 加 'note'(v27 个人笔记的图片块)。 + * ⚠️ 判据用 strpos 找 'note' 要小心:ENUM 串里 'trip_node'、'task_comment' 都 + * 不含 note,所以直接找 `'note'`(带引号)最稳,不必担心误判。 + */ + private static function widenAttachTargetEnum(\PDO $db): void + { + $st = $db->prepare( + "SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'attachments' AND COLUMN_NAME = 'target_type'" + ); + $st->execute(); + $type = (string)$st->fetchColumn(); + if ($type !== '' && strpos($type, "'note'") === false) { + $db->exec( + "ALTER TABLE `attachments` MODIFY COLUMN `target_type` + ENUM('trip','trip_node','resource','task','task_comment','note') NOT NULL + COMMENT '挂在谁身上'" + ); + } + } + private static function hasIndex(\PDO $db, string $table, string $index): bool { $st = $db->prepare( diff --git a/assets/css/app.css b/assets/css/app.css index d4abf1f..16e18d4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4465,3 +4465,109 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b .nav-reuse-f { font-size: 12px; color: var(--muted); margin-top: 8px; } /* 空 img(没有图标可复用)别占位成一个破图框 */ .nav-reuse-item img:not([src]) { visibility: hidden; } + +/* ============================================================ + v27 个人笔记(用户 2026-10-07) + 参考 flomo(时间流卡片 + 标签)/ Obsidian(图文混排的直觉)/ 思源(分块), + 但一律取**轻**的那一层:块只有 段落/图片/清单,不做块引用、不做富文本。 + 配色沿用宣纸米 + 砖红,不引入第三套色。 + ============================================================ */ + +/* ---- 列表页:左标签云 + 右卡片流 ---- */ +.note-layout { display: grid; grid-template-columns: 220px 1fr; gap: 16px; align-items: start; } +.note-side { position: sticky; top: 16px; } +.note-main { min-width: 0; } +.note-search-card { margin-bottom: 12px; } +.note-search-card .search { margin: 0; } + +.note-tags { display: flex; flex-wrap: wrap; gap: 6px; } +.note-tag { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 9px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--line); background: #fff; + font-size: 12.5px; color: var(--muted); text-decoration: none; +} +.note-tag:hover { border-color: var(--brand-2); color: var(--brand-ink); text-decoration: none; } +.note-tag.is-on { border-color: var(--brand); color: var(--brand-ink); background: var(--brand-soft, #FDF3EC); } +.note-tag b { font-weight: 600; color: var(--faint); font-variant-numeric: tabular-nums; } +.note-tag.is-on b { color: var(--brand-ink); } +.note-tags-inline { margin-top: 10px; } +.note-tags-suggest { margin-top: 8px; align-items: center; } + +/* ---- 卡片 ---- */ +.note-card { margin-bottom: 12px; } +.note-head { display: flex; align-items: center; gap: 8px; } +.note-meta { display: flex; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; } +.note-ava { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } +.note-who { font-size: 13px; color: var(--ink); font-weight: 500; } +.note-time { font-size: 12px; color: var(--faint); margin-left: auto; padding-right: 4px; } +/* 共享/私有标记:一眼分清"这是我写给自己看的"还是"全家都能看的" */ +.note-share, .note-private { + font-size: 11px; padding: 1px 7px; border-radius: 999px; flex: 0 0 auto; +} +.note-share { color: #2F6B4F; background: #E7F2EC; } +.note-private { color: var(--faint); background: var(--line-2); } +.note-ops { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.note-ops .inline-form { display: inline; } + +.note-title { margin: 10px 0 6px; font-size: 16px; font-weight: 600; color: var(--ink); } +.note-body { display: flex; flex-direction: column; gap: 8px; } +.note-text { margin: 0; font-size: 14.5px; line-height: 1.7; color: var(--ink); white-space: pre-wrap; } +.note-list { margin: 0; padding-left: 20px; font-size: 14.5px; line-height: 1.7; color: var(--ink); } +.note-list li { margin: 2px 0; } +/* 图片:等比缩到卡片宽,圆角与卡片一致;点开走全站预览层(data-prev) */ +.note-img { display: block; } +.note-img img { + max-width: 100%; max-height: 420px; border-radius: var(--radius-s); + border: 1px solid var(--line); display: block; +} +.note-more { text-align: center; margin: 16px 0 8px; } + +/* ---- 编辑页:分块 ---- */ +.note-form .card { margin-bottom: 12px; } +.note-block-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.note-block-head .cinema-h2 { margin: 0; flex: 1 1 auto; } +.note-add { display: flex; gap: 6px; flex: 0 0 auto; } +.note-blocks { display: flex; flex-direction: column; gap: 8px; } +.note-block { + display: flex; gap: 8px; align-items: flex-start; + border: 1px dashed var(--line); border-radius: var(--radius-s); + padding: 8px; background: #FDFCFA; +} +.note-block:focus-within { border-color: var(--brand-2); border-style: solid; } +.note-block-ops { display: flex; flex-direction: column; gap: 4px; flex: 0 0 auto; } +.nb-btn { + width: 24px; height: 22px; line-height: 1; padding: 0; + border: 1px solid var(--line); border-radius: 5px; background: #fff; + color: var(--muted); cursor: pointer; font-size: 12px; +} +.nb-btn:hover { border-color: var(--brand-2); color: var(--brand-ink); } +.nb-del:hover { border-color: var(--red); color: var(--red); } +.note-block-body { flex: 1 1 auto; min-width: 0; } +.note-block-ta { width: 100%; resize: vertical; font-size: 14.5px; line-height: 1.7; } +.note-block-img { + max-width: 100%; max-height: 320px; border-radius: var(--radius-s); + border: 1px solid var(--line); display: block; margin-bottom: 6px; +} +/* 还没选图时:不要显示破图框(那是 的默认表现,很难看) */ +.note-block-img.is-empty { display: none; } +.note-img-pick { + padding: 4px 10px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: #fff; color: var(--muted); cursor: pointer; font-size: 12.5px; +} +.note-img-pick:hover { border-color: var(--brand-2); color: var(--brand-ink); } +.note-share-toggle { + display: flex; align-items: center; gap: 8px; margin-top: 12px; + font-size: 13.5px; color: var(--muted); cursor: pointer; +} +.note-share-toggle input { width: 16px; height: 16px; accent-color: var(--brand); } +.note-actions { display: flex; gap: 8px; margin: 4px 0 20px; } + +/* ---- 窄屏:标签云折到上面,卡片满宽 ---- */ +@media (max-width: 760px) { + .note-layout { grid-template-columns: 1fr; } + .note-side { position: static; } + .note-side .card { margin-bottom: 12px; } + /* 长英文/URL 不要把卡片撑出横向滚动(全站反复栽在这条上) */ + .note-text, .note-list, .note-title { overflow-wrap: anywhere; } +} diff --git a/assets/js/note_editor.js b/assets/js/note_editor.js new file mode 100644 index 0000000..49e252d --- /dev/null +++ b/assets/js/note_editor.js @@ -0,0 +1,243 @@ +/** + * 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。 + * + * 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明): + * ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea) + * ② 增(+段落 / +图片 / +清单)、删、上移、下移 + * ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块 + * ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks` + * + * ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里: + * 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、 + * 每次操作都按数组重新渲染,那次输入就会丢。改成"DOM 即真相": + * textarea 的值**就在 DOM 里**,移动只是把一个节点挪个位置,输入一字不丢。 + * 只有提交时才从 DOM 读一遍(serialize())。 + * + * ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删, + * 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。 + */ +(function () { + 'use strict'; + + var form = document.getElementById('noteForm'); + var list = document.getElementById('noteBlockList'); + var sink = document.getElementById('noteBlocks'); + var seed = document.getElementById('noteSeed'); + var fileIn = document.getElementById('noteImgInput'); + if (!form || !list || !sink) { return; } + + // 上传目标(哪个图片块在等这次上传的结果) + var pendingBlock = null; + + // --------------------------------------------------------------------- + // 渲染一块 + // --------------------------------------------------------------------- + // media 出口的基址由模板注入(页面上的 `data-media-base`)—— +// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。 + var mediaBase = (document.body.getAttribute('data-media-base') || 'media.php?k='); + + function makeBlock(kind, content) { + var wrap = document.createElement('div'); + wrap.className = 'note-block'; + wrap.setAttribute('data-kind', kind); + + var ops = document.createElement('div'); + ops.className = 'note-block-ops'; + ops.innerHTML = + '' + + '' + + ''; + wrap.appendChild(ops); + + var body = document.createElement('div'); + body.className = 'note-block-body'; + + if (kind === 'image') { + // 图片块:content 是附件 key。填 key 的同时把预览换出来。 + var img = document.createElement('img'); + img.className = 'note-block-img'; + img.alt = '笔记图片'; + img.src = content ? (mediaBase + encodeURIComponent(content)) : ''; + if (!content) { img.classList.add('is-empty'); } + var hint = document.createElement('button'); + hint.type = 'button'; + hint.className = 'note-img-pick'; + hint.textContent = content ? '换一张' : '选图片'; + hint.setAttribute('data-op', 'pick'); + body.appendChild(img); + body.appendChild(hint); + body.setAttribute('data-key', content || ''); + } else { + var ta = document.createElement('textarea'); + ta.className = 'input note-block-ta'; + ta.rows = kind === 'list' ? 4 : 3; + ta.value = content || ''; + ta.placeholder = kind === 'list' + ? '一行一项,回车换行' + : (kind === 'text' ? '写点什么…' : ''); + // 清单块自动在每行前加「· 」的视觉提示(内容本身不加,保持纯文本) + body.appendChild(ta); + } + wrap.appendChild(body); + return wrap; + } + + // --------------------------------------------------------------------- + // 序列化:从 DOM 读回块数组,写进隐藏域 + // --------------------------------------------------------------------- + function serialize() { + var out = []; + Array.prototype.forEach.call(list.children, function (el) { + var kind = el.getAttribute('data-kind') || 'text'; + var content; + if (kind === 'image') { + var b = el.querySelector('.note-block-body'); + content = (b && b.getAttribute('data-key')) || ''; + } else { + var ta = el.querySelector('textarea'); + content = ta ? ta.value : ''; + } + out.push({ kind: kind, content: content }); + }); + sink.value = JSON.stringify(out); + } + + // --------------------------------------------------------------------- + // 增 / 删 / 移 + // --------------------------------------------------------------------- + function addBlock(kind, content, afterEl) { + var el = makeBlock(kind, content); + if (afterEl && afterEl.parentNode === list) { + list.insertBefore(el, afterEl.nextSibling); + } else { + list.appendChild(el); + } + var ta = el.querySelector('textarea'); + if (ta) { ta.focus(); } + serialize(); + return el; + } + + function move(el, dir) { + if (dir < 0) { + var prev = el.previousElementSibling; + if (prev) { list.insertBefore(el, prev); } + } else { + var next = el.nextElementSibling; + if (next) { list.insertBefore(next, el); } + } + serialize(); + } + + // 事件委托:统一处理块上的所有按钮 + list.addEventListener('click', function (e) { + var btn = e.target.closest('[data-op]'); + if (!btn || !list.contains(btn)) { return; } + var op = btn.getAttribute('data-op'); + var el = btn.closest('.note-block'); + if (!el) { return; } + + if (op === 'up') { move(el, -1); return; } + if (op === 'down') { move(el, 1); return; } + if (op === 'del') { el.parentNode.removeChild(el); serialize(); return; } + if (op === 'pick') { + // 记住是哪个块在等图片,然后触发隐藏 file 框 + pendingBlock = el; + fileIn.click(); + return; + } + }); + + // 顶部「+段落 / +图片 / +清单」 + document.querySelectorAll('.note-add [data-add]').forEach(function (b) { + b.addEventListener('click', function () { + var kind = b.getAttribute('data-add'); + if (kind === 'image') { + // 加图块的同时直接把选图流程带起来(少一次点击) + var el = addBlock('image', ''); + pendingBlock = el; + fileIn.click(); + } else { + addBlock(kind, ''); + } + }); + }); + + // --------------------------------------------------------------------- + // 图片上传 + // --------------------------------------------------------------------- + fileIn.addEventListener('change', function () { + if (!fileIn.files || !fileIn.files[0]) { return; } + var el = pendingBlock; + if (!el) { return; } + var f = fileIn.files[0]; + + var fd = new FormData(); + fd.append('img', f); + // CSRF:从表单里那颗隐藏 _csrf 拿(同一个 token) + var tok = form.querySelector('input[name="_csrf"]'); + if (tok) { fd.append('_csrf', tok.value); } + + // 上传中:把按钮文案换掉,避免用户以为没反应又点一次 + var pick = el.querySelector('.note-img-pick'); + var oldTxt = pick ? pick.textContent : ''; + if (pick) { pick.textContent = '上传中…'; pick.disabled = true; } + + fetch('index.php?r=notes/upload', { + method: 'POST', + body: fd, + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (r) { return r.json().catch(function () { return null; }); }) + .then(function (d) { + if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; } + if (!d || !d.ok) { + alert('图片上传失败:' + ((d && d.error) || '未知原因')); + return; + } + var b = el.querySelector('.note-block-body'); + if (b) { b.setAttribute('data-key', d.key); } + var img = el.querySelector('.note-block-img'); + if (img) { img.src = d.url; img.classList.remove('is-empty'); } + if (pick) { pick.textContent = '换一张'; } + serialize(); + }).catch(function () { + if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; } + alert('图片上传失败,请重试。'); + }); + // 清空 input,否则连选同一张图不会再触发 change + fileIn.value = ''; + }); + + // textarea 输入时同步(这样即便用户直接点提交,内容也是最新的) + list.addEventListener('input', serialize); + + // 提交前再序列化一次(双保险) + form.addEventListener('submit', serialize); + + // --------------------------------------------------------------------- + // 初始化:从服务端种子渲染 + // --------------------------------------------------------------------- + var initial = []; + if (seed) { + try { initial = JSON.parse(seed.textContent || '[]'); } catch (e) { initial = []; } + } + if (!initial.length) { initial = [{ kind: 'text', content: '' }]; } + initial.forEach(function (b) { addBlock(b.kind, b.content); }); + serialize(); + + // --------------------------------------------------------------------- + // 标签联想:点一下把 #标签 追加进输入框 + // --------------------------------------------------------------------- + var tagIn = document.getElementById('noteTags'); + document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) { + b.addEventListener('click', function () { + if (!tagIn) { return; } + var t = '#' + b.getAttribute('data-tag'); + var cur = tagIn.value.trim(); + // 已经有就不重复加 + if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; } + tagIn.value = cur === '' ? t : (cur + ' ' + t); + }); + }); +})(); \ No newline at end of file diff --git a/index.php b/index.php index fd8e5d3..efc3bbd 100644 --- a/index.php +++ b/index.php @@ -43,6 +43,7 @@ use App\Nav; use App\NavFetch; use App\NodeType; use App\Nickname; +use App\Note; use App\Person; use App\PersonGroup; use App\PersonJob; @@ -6212,6 +6213,159 @@ switch ($r) { } break; + /* ===================================================================== + * 个人笔记(v27 用户 2026-10-07) + * + * 「参考思源和 obsidian,采用分块方式,支持图文混排,注意,要轻量级,别太复杂, + * 兼顾笔记和标签功能,哦,再参考一下 flomo」 + * + * 路由就这几条(刻意少):列表(含搜索/标签筛选) / 表单(新建+编辑同页) / + * 保存(create+update 合一) / 删除 / 图片上传。 + * ⚠️ 新建与编辑走**同一个表单页**(`notes/form`):两者字段完全一样, + * 分成两个模板只会让改一处忘一处(本仓在 resources/books 上已踩过)。 + * ===================================================================== */ + + case 'notes': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $keyword = trim((string)($_GET['keyword'] ?? '')); + $tag = trim((string)($_GET['tag'] ?? '')); + $showAll = !empty($_GET['all']) || $keyword !== '' || $tag !== ''; + $rows = Note::all($familyId, $me, $keyword, $tag, + $showAll ? 0 : Note::LIST_LIMIT); + View::render('notes/index', [ + 'title' => '笔记', + 'rows' => $rows, + 'keyword' => $keyword, + 'tag' => $tag, + 'tagCloud' => Note::tagCloud($familyId, $me), + 'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag), + 'showAll' => $showAll, + 'limit' => Note::LIST_LIMIT, + ], 'notes'); + break; + + /* 新建 / 编辑:同一套表单(`id>0` 就是编辑)。 + ⚠️ 编辑时**只能改自己的**:别人的笔记即使共享给全家也只是"看得到", + 点进编辑页会被挡回列表(不是静默变成新建,那样用户会以为改成功了)。 */ + case 'notes/form': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + $id = (int)($_GET['id'] ?? 0); + $editing = null; + if ($id > 0) { + $editing = Note::find($familyId, $id, $me); + if ($editing === null || empty($editing['is_mine'])) { + flash('err', '只能编辑自己的笔记。'); + redirect('notes'); + } + } + View::render('notes/form', [ + 'title' => $editing ? '改笔记' : '写笔记', + 'editing' => $editing, + 'tagSuggest' => Note::tagSuggest($familyId, $me), + ], 'notes'); + break; + + case 'notes/save': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + // 表单把块拼成 JSON 提交(`blocks` 字段)—— 块的增删排序本来就是个列表, + // 拆成一堆并行数组(kind[]/content[])在服务端还得按索引对齐,容易错位。 + $blocks = []; + $rawBlocks = (string)($_POST['blocks'] ?? ''); + if ($rawBlocks !== '') { + $dec = json_decode($rawBlocks, true); + if (is_array($dec)) { + $blocks = $dec; + } + } + // 兜底:没有 JS(或 JS 没跑到)时,退化成"一个正文textarea + 标题" + // —— 这样纯服务端也能写出一条文字笔记,不会因为缺 JS 就完全不能用。 + if (!$blocks) { + $plain = (string)($_POST['body_plain'] ?? ''); + if (trim($plain) !== '') { + $blocks = [['kind' => 'text', 'content' => $plain]]; + } + } + $payload = [ + 'title' => (string)($_POST['title'] ?? ''), + 'blocks' => $blocks, + 'tags' => (string)($_POST['tags'] ?? ''), + 'is_shared' => !empty($_POST['is_shared']), + ]; + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + $done = Note::update($familyId, $id, $me, $payload); + flash($done ? 'ok' : 'err', $done ? '改好了。' : '只能改自己的笔记。'); + redirect('notes'); + } + $nid = Note::create($familyId, $me, $payload); + flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '什么都没写,没记。'); + redirect('notes'); + break; + + case 'notes/destroy': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $n = Note::find($familyId, $id, $me); + if (!$n || empty($n['is_mine'])) { + flash('err', '只能删自己的笔记。'); + redirect('notes'); + } + Note::destroy($familyId, $id, $me); + flash('ok', '删掉了。'); + redirect('notes'); + break; + + /* 图片块上传:走统一附件表(target_type='note')。 + ⚠️ 为什么复用 attachments 而不在 notes 里另起一套: + 图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份, + 每个模块各写一遍迟早出现"某个模块的图不压缩"或"路径拼错 404"。 + ⚠️ 上传是**即时**的(写笔记时点加图就传),此刻笔记可能还不存在 —— + 所以附件行先挂在 `target_type='note'` + `target_id=0` 上(草稿态), + 前端拿到 `key` 后把它当一个 image 块随表单提交;笔记保存时 + `Note::claimUploads()` 把这些草稿附件认领到真实 note_id 上。 + ⚠️ 认领是**必须的**:不做的话草稿附件会永远挂在 target_id=0 上, + 既不会被"删笔记时连图一起删"覆盖到,也会在附件表里越积越多。 */ + case 'notes/upload': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + header('Content-Type: application/json; charset=utf-8'); + if (empty($_FILES['img'])) { + echo json_encode(['ok' => false, 'error' => '没有收到文件'], JSON_UNESCAPED_UNICODE); + break; + } + $r = Attachment::save($familyId, Note::ATTACH, 0, $_FILES['img'], + (int)Auth::personId()); + if (empty($r['ok'])) { + echo json_encode(['ok' => false, 'error' => (string)($r['error'] ?? '上传失败')], + JSON_UNESCAPED_UNICODE); + break; + } + // 取回刚存的那一行拿 key(Attachment 没有 find(),用 list 按 id 定位) + $key = ''; + foreach (Attachment::list(Note::ATTACH, 0) as $a) { + if ((int)$a['id'] === (int)$r['id']) { + $key = (string)$a['key']; + break; + } + } + echo json_encode([ + 'ok' => $key !== '', + 'key' => $key, + 'id' => (int)$r['id'], + 'url' => $key !== '' ? media_url($key) : '', + ], JSON_UNESCAPED_UNICODE); + break; + default: http_response_code(404); View::render('error', [ diff --git a/templates/layout.php b/templates/layout.php index 26066c4..3b3d802 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -72,6 +72,7 @@ $isLoggedIn = \App\Auth::check(); ⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 —— 模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?> @@ -163,6 +164,18 @@ $isLoggedIn = \App\Auth::check(); document.head.appendChild(s); })(); + + diff --git a/templates/notes/form.php b/templates/notes/form.php new file mode 100644 index 0000000..dd66ce4 --- /dev/null +++ b/templates/notes/form.php @@ -0,0 +1,140 @@ +` 里给一个普通 textarea(`body_plain`), + * 服务端在 `blocks` 为空时会退回读它(见 index.php 的 notes/save)。 + * + * @param array|null $editing 编辑时的笔记(带 blocks/tags/is_shared);新建为 null + * @param array $tagSuggest 我用过的标签(点一下就加) + */ +$editing = $editing ?? null; +$isEditing = $editing !== null; +$canWrite = \App\Auth::canWrite(); + +// 把编辑中的数据整理成前端要的形状(块数组 + 标签词) +$blocks = []; +$tagsStr = ''; +if ($isEditing) { + foreach ((array)$editing['blocks'] as $b) { + $blocks[] = ['kind' => (string)$b['kind'], 'content' => (string)$b['content']]; + } + $tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags'])); +} +if (!$blocks) { + // 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写) + $blocks = [['kind' => 'text', 'content' => '']]; +} +?> +
+

+ +
+ + +

只读账号不能写笔记。

+ +
+ + + + + + + +
+
+ + +
+
+ + +
+
+

正文

+
+ + + +
+
+ +
+ + + + + + +
+ + +
+
+ + + +
+ 用过的: + + + +
+ +
+ + +
+ +
+ + 取消 +
+
+ + 而不是 data-* —— + 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> + + \ No newline at end of file diff --git a/templates/notes/index.php b/templates/notes/index.php new file mode 100644 index 0000000..c9028e8 --- /dev/null +++ b/templates/notes/index.php @@ -0,0 +1,168 @@ + +
+

笔记

+ 共 条 +
+ + 写笔记 + + 只读账号 + +
+
+ +
+ + + + +
+
+ +
+ + +
+

+ + 没有匹配的笔记。 + + 还没有笔记。点右上角「写笔记」随手记一条吧。 + + 还没有笔记。 + +

+
+ + + +
+
+
+ + + + + 共享 + + 私有 + + + +
+ +
+ 编辑 +
+ + + +
+
+ +
+ + +

+ + + +
+ + + + + 笔记图片 + + +
    + + + +
  • + + +
+ +

+ + +
+ + +
+ + + # + + +
+ +
+ + + count($rows)): ?> + + + +
+
\ No newline at end of file