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:
+4
-1
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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', [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user