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:
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user