v27 个人笔记模块:分块(段落/图片/清单)+ 单层多标签 + 默认私有可共享

参考 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 数组:移动块时用户已输入的内容一字不丢
This commit is contained in:
2026-10-07 18:22:55 +08:00
parent c8839fa728
commit 404b1afee0
9 changed files with 1432 additions and 3 deletions
+84 -2
View File
@@ -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(