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
+4 -1
View File
@@ -51,6 +51,9 @@ final class Module
// 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以 // 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以
// 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。 // 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。
'nav' => ['导航', '#3E7A9E', 'nav'], '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', 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'; private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
+520
View File
@@ -0,0 +1,520 @@
<?php
/**
* 「个人笔记」(v24 第 27 批,用户 2026-10-07)。
*
* 用户原话:「增加个人笔记模块,参考思源和 obsidian,采用分块方式,支持图文混排,
* 注意,要轻量级,别太复杂,兼顾笔记和标签功能,哦,再参考一下 flomo」
*
* 三处参考各取一点,但**坚决不做重的东西**:
*
* · **flomo** —— 「一行一条、随手记、按标签回看」的轻量劲儿:
* 写的时候只有标题 + 正文 + 标签;列表是**时间轴卡片流**;标签一点即筛。
* · **Obsidian** —— 「双链、Markdown、本地真实文件夹」全部**不做**
* (那是编辑器与知识图谱的领域,一个家庭应用扛不动,也不是用户要的)。
* 只取它「一个条目里图文混排」的直觉。
* · **思源** —— 取「**块**」这个粒度:一条笔记由若干块组成,每块可以是
* 段落 / 图片 / 清单,可增删、可上下移。但**不做**块引用、块 ID 双链、
* 数据库视图这些重家伙。
*
* 于是落到数据模型上,一共三张表(都很薄):
*
* · `notes` —— 一条笔记 = 一张卡片(标题 + 可见性 + 归属人)
* · `note_blocks`—— 卡内的一块(kind: text / image / list,内容 + 排序)
* · `note_tags` —— 笔记 ↔ 标签(单层标签,一条可挂多个;**标签词本身不建表**,
* 用字符串直接存 —— 见 addTags() 的说明)
*
* 权限(用户拍板):
* 默认**私有**(只有作者自己看得到);单条可勾「共享给全家」,共享后在列表里
* 带一个标记。不做只读/读写分级 —— 那是资源/任务那类"协作物件"才需要的,
* 笔记是"我写的东西",别人只该看,不该改。
*
* ⚠️ 与既有模块的分工(别混):
* · `person_notes` —— **我对某个人的**私人备注(挂在名册那个人身上),不是这里;
* · `book_notes` —— 读书笔记(挂在书上);
* · `notes` —— **我自己的**笔记,与"某个人""某本书"无关。
*/
declare(strict_types=1);
namespace App;
final class Note
{
/** 附件(图片块)在统一 attachments 表里的宿主类型 */
public const ATTACH = 'note';
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
public const BLOCK_KINDS = ['text', 'image', 'list'];
/** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */
public const MAX_BLOCKS = 60;
public const MAX_TEXT = 20000;
/** 列表页默认显示多少条(超过给"加载更多") */
public const LIST_LIMIT = 30;
// =====================================================================
// 读
// =====================================================================
/**
* 我能看到的笔记 = 我自己的 + 别人共享给全家的。
*
* @param int $viewer 看的人
* @param string $keyword 标题/正文/标签里搜
* @param string $tag 只看这个标签('' = 不限)
* @param int $limit 0 = 不限
* @param int $offset 分页偏移
* @return array<int,array> 每行带 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<int,array{kind:string,content:string}>
*/
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<int,string> 去重后的标签(最多 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;
}
}
+84 -2
View File
@@ -97,6 +97,10 @@ final class Schema
* 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了 * 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了
* (所有设备上的同步立刻 401)。而且必须先判旧表在不在: * (所有设备上的同步立刻 401)。而且必须先判旧表在不在:
* 全新安装时没有旧表,直接 RENAME 会报错把整段升级打断。 * 全新安装时没有旧表,直接 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() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '26'; public const VERSION = '27';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -448,7 +452,7 @@ SQL,
CREATE TABLE IF NOT EXISTS `attachments` ( CREATE TABLE IF NOT EXISTS `attachments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL, `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', `target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id',
`key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)', `key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)',
`name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)', `name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)',
@@ -915,6 +919,57 @@ CREATE TABLE IF NOT EXISTS `nav_settings` (
PRIMARY KEY (`person_id`, `k`), PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)' ) 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, SQL,
]; ];
} }
@@ -1331,6 +1386,11 @@ SQL);
// 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改, // 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改,
// 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。 // 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。
self::widenEventKindEnum($db); self::widenEventKindEnum($db);
// v27:attachments.target_type 要能挂笔记的图片('note')。
// ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表**
// 不会更新 —— 老库必须显式 MODIFY,否则上传笔记图片时插入
// target_type='note' 会被严格模式整条拒掉(表现为"图传不上去")。
self::widenAttachTargetEnum($db);
// 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在 // 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在
// 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。 // 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。
self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) { 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 private static function hasIndex(\PDO $db, string $table, string $index): bool
{ {
$st = $db->prepare( $st = $db->prepare(
+106
View File
@@ -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; } .nav-reuse-f { font-size: 12px; color: var(--muted); margin-top: 8px; }
/* 空 img(没有图标可复用)别占位成一个破图框 */ /* 空 img(没有图标可复用)别占位成一个破图框 */
.nav-reuse-item img:not([src]) { visibility: hidden; } .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;
}
/* 还没选图时:不要显示破图框(那是 <img src=""> 的默认表现,很难看) */
.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; }
}
+243
View File
@@ -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 =
'<button type="button" class="nb-btn" data-op="up" title="上移" aria-label="上移">↑</button>' +
'<button type="button" class="nb-btn" data-op="down" title="下移" aria-label="下移">↓</button>' +
'<button type="button" class="nb-btn nb-del" data-op="del" title="删除" aria-label="删除">✕</button>';
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);
});
});
})();
+154
View File
@@ -43,6 +43,7 @@ use App\Nav;
use App\NavFetch; use App\NavFetch;
use App\NodeType; use App\NodeType;
use App\Nickname; use App\Nickname;
use App\Note;
use App\Person; use App\Person;
use App\PersonGroup; use App\PersonGroup;
use App\PersonJob; use App\PersonJob;
@@ -6212,6 +6213,159 @@ switch ($r) {
} }
break; 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: default:
http_response_code(404); http_response_code(404);
View::render('error', [ View::render('error', [
+13
View File
@@ -72,6 +72,7 @@ $isLoggedIn = \App\Auth::check();
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 —— ⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?> 模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<body class="mod-<?= e($moduleKey) ?>" <body class="mod-<?= e($moduleKey) ?>"
data-media-base="media.php?k="
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;"> style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;">
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
@@ -163,6 +164,18 @@ $isLoggedIn = \App\Auth::check();
document.head.appendChild(s); document.head.appendChild(s);
})(); })();
</script> </script>
<?php /* v27:笔记的分块编辑器同样**按需加载**(页面上真有 `#noteForm` 才拉)。
理由与 person_picker.js 完全同一套:它只在写笔记那一页有用,
每页都带一份等于让别的页面白付流量。判据用表单的真实 id(不是含糊找 "note")。 */ ?>
<script>
(function () {
if (!document.getElementById('noteForm')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/note_editor.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<script src="<?= e(asset('/assets/js/filter.js')) ?>"></script> <script src="<?= e(asset('/assets/js/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script> <script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
</body> </body>
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 个人笔记 · 写 / 改(v27 用户 2026-10-07)。新建与编辑**同一个模板**。
*
* 「分块」的落点就在这一页:正文区由若干**块**组成,每块是
* 段落 / 图片 / 清单 三者之一,可增、可删、可上下移。
*
* 三条刻意的轻量取舍(写清楚,免得后人"顺手升级"成重型编辑器):
* ① **不做富文本工具栏**(粗体/斜体/颜色…)。本项目全站的文本都是"纯文本 +
* 换行",只有这里破例就会出现两套文本口径(导出、搜索、复制都会不一致)。
* 需要强调就写符号,需要排版就用清单块。
* ② **不做 Markdown 预览**。可见即所得:段落块就是段落,清单块就是清单 ——
* 用户不必再学"我打的东西会变成什么"。这也是"轻量"最实在的一层。
* ③ **不做拖拽排序**,只有 ↑ ↓ 两个按钮。鼠标拖拽要引入一整套 pointer 事件
* 与触摸兼容(本仓名册拖拽就为它写了上千行),而笔记的块通常只有几块,
* 两个按钮足够,代码少一个数量级。
*
* ⚠️ 提交形状:所有块打包成一个 JSON 放进隐藏域 `blocks`(见页尾脚本)。
* 为什么不是 `kind[]` / `content[]` 并行数组:服务端要按下标对齐,
* 一旦前端少发一个(比如空块被过滤)整个错位,而且很难在页面上看出来。
* JSON 一把梭,服务端 `json_decode` 之后逐块清洗,简单且不会错位。
* ⚠️ **无 JS 兜底**:`<noscript>` 里给一个普通 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' => '']];
}
?>
<div class="page-head">
<h1><?= $isEditing ? '改笔记' : '写笔记' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">返回列表</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
<?php else: ?>
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<?php /* 块的真实载荷(由页尾脚本维护) */ ?>
<input type="hidden" name="blocks" id="noteBlocks" value="">
<div class="card card-pad">
<div class="field">
<label for="noteTitle">标题</label>
<input class="input" id="noteTitle" name="title" maxlength="200"
value="<?= e((string)($editing['title'] ?? '')) ?>"
placeholder="一句话概括(也可以不写,只写正文)" autocomplete="off">
</div>
</div>
<?php /* ---------------- 分块编辑区 ---------------- */ ?>
<div class="card card-pad">
<div class="note-block-head">
<h2 class="cinema-h2">正文</h2>
<div class="note-add">
<button class="btn btn-ghost btn-sm" type="button" data-add="text">+ 段落</button>
<button class="btn btn-ghost btn-sm" type="button" data-add="image">+ 图片</button>
<button class="btn btn-ghost btn-sm" type="button" data-add="list">+ 清单</button>
</div>
</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>
<label class="note-share-toggle">
<input type="checkbox" name="is_shared" value="1"
<?= !empty($editing['is_shared']) ? 'checked' : '' ?>>
<span>共享给全家(默认只有自己看得到)</span>
</label>
</div>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
</div>
</form>
<?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; ?>
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* 个人笔记 · 列表(v27 用户 2026-10-07)。
*
* 形态参考 **flomo**:一条一张卡片、按时间倒序的流;顶部一个搜索框;
* 侧边一列标签云(点一下只看这个标签)。
* 「图文混排」在**卡片里就看得见**(图以缩略图排在文字之下),
* 不必点进去 —— 笔记是"回看"的东西,回看时应该一眼看到内容。
*
* 分块只在**编辑页**体现(卡内可加段/图/清单并上下移);列表页只做**只读渲染**,
* 这是刻意的取舍:列表页可编辑就得为每张卡片配一套 JS 状态,与"轻量级"冲突。
*
* @param array $rows 我能看到的笔记(带 blocks / tags / is_mine)
* @param array $tagCloud 标签 + 条数
* @param string $keyword 搜索词
* @param string $tag 当前筛的标签
* @param int $totalAll 一共几条
* @param bool $showAll 是否已展开全部
* @param int $limit 列表默认条数
*/
$canWrite = \App\Auth::canWrite();
$myPerson = (int)\App\Auth::personId();
?>
<div class="page-head">
<h1>笔记</h1>
<span class="sub">共 <?= (int)$totalAll ?> 条</span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('notes/form')) ?>">写笔记</a>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<div class="note-layout">
<?php /* ---------------- 左侧:标签云 ---------------- */ ?>
<aside class="note-side">
<div class="card card-pad">
<h2 class="cinema-h2">标签</h2>
<?php if (!$tagCloud): ?>
<p class="empty-hint">还没有标签。写笔记时加上 <code>#标签</code> 就会出现在这里。</p>
<?php else: ?>
<div class="note-tags">
<a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b>
</a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tag === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('notes', ['tag' => (string)$t['tag']])) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</aside>
<?php /* ---------------- 右侧:卡片流 ---------------- */ ?>
<div class="note-main">
<div class="card card-pad note-search-card">
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="notes">
<?php if ($tag !== ''): ?>
<input type="hidden" name="tag" value="<?= e($tag) ?>">
<?php endif; ?>
<label class="sr-only" for="noteKw">搜索笔记</label>
<input class="input" id="noteKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文 / 标签,如「读书」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== '' || $tag !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">清除</a>
<?php endif; ?>
</form>
</div>
<?php if (!$rows): ?>
<div class="card card-pad">
<p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?>
没有匹配的笔记。
<?php elseif ($canWrite): ?>
还没有笔记。点右上角「写笔记」随手记一条吧。
<?php else: ?>
还没有笔记。
<?php endif; ?>
</p>
</div>
<?php else: ?>
<?php foreach ($rows as $n): ?>
<?php $mine = !empty($n['is_mine']); ?>
<article class="card card-pad note-card">
<header class="note-head">
<div class="note-meta">
<?php /* 头像 + 作者:共享笔记要一眼看出是谁写的 */ ?>
<img class="note-ava" src="<?= e(avatar_url($n['avatar'] ?? null)) ?>" alt="">
<span class="note-who"><?= e((string)$n['author_name']) ?></span>
<?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span>
<?php else: ?>
<span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?>
<?php /* 只显示到分钟、且把 T 换成空格后的形态(全站惯例:不引第三方时间库) */ ?>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</div>
<?php if ($mine && $canWrite): ?>
<div class="note-ops">
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes/form', ['id' => (int)$n['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</header>
<?php if (trim((string)$n['title']) !== ''): ?>
<h3 class="note-title"><?= e((string)$n['title']) ?></h3>
<?php endif; ?>
<?php /* 块渲染:文字段 / 图片 / 清单 —— 「图文混排」就在这儿 */ ?>
<div class="note-body">
<?php foreach ((array)$n['blocks'] as $b): ?>
<?php $kind = (string)$b['kind']; $c = (string)$b['content']; ?>
<?php if ($kind === 'image'): ?>
<a class="note-img" href="<?= e(media_url($c)) ?>"
data-prev="image" data-prev-src="<?= e(media_url($c)) ?>">
<img src="<?= e(media_url($c)) ?>" alt="笔记图片" loading="lazy">
</a>
<?php elseif ($kind === 'list'): ?>
<ul class="note-list">
<?php foreach (preg_split('/\R/u', $c) ?: [] as $line): ?>
<?php $line = trim((string)$line); ?>
<?php if ($line !== ''): ?>
<li><?= e($line) ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="note-text"><?= nl2br(e($c)) ?></p>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline">
<?php foreach ((array)$n['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">
#<?= e((string)$t) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more">
<a class="btn btn-ghost" href="<?= e(url('notes', ['all' => 1, 'keyword' => $keyword, 'tag' => $tag])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>