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
+154
View File
@@ -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', [