v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:
1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
`if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
(实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
`<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
(项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
`php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
表单带 back 域 → 保存后回原来那一项。
🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
This commit is contained in:
+234
-31
@@ -43,8 +43,15 @@ final class Note
|
|||||||
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
||||||
public const ATTACH = 'note';
|
public const ATTACH = 'note';
|
||||||
|
|
||||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
|
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
|
||||||
public const BLOCK_KINDS = ['text', 'image', 'list'];
|
*
|
||||||
|
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||||
|
* 把段落做的完善一点就可以了。」
|
||||||
|
* ⇒ 收成 `text` / `image` 两种。
|
||||||
|
* ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉,
|
||||||
|
* 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。
|
||||||
|
* 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */
|
||||||
|
public const BLOCK_KINDS = ['text', 'image'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
||||||
@@ -59,6 +66,22 @@ final class Note
|
|||||||
/** 列表页默认显示多少条(超过给"加载更多") */
|
/** 列表页默认显示多少条(超过给"加载更多") */
|
||||||
public const LIST_LIMIT = 30;
|
public const LIST_LIMIT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **可见性的唯一判据**(v29 第 9 条)。
|
||||||
|
*
|
||||||
|
* 「这条笔记是我写的」**或**「有一条指向我的共享记录」。
|
||||||
|
* ⚠️ 全站(列表 / 计数 / 详情 / 标签云)都拼这**同一个**串 ——
|
||||||
|
* 各写各的迟早出现"列表里看得到、点进去 404"这种鬼故事。
|
||||||
|
* ⚠️ 绑定参数顺序:调用方在它**前面**绑 `$viewer`(对应 n.person_id)。
|
||||||
|
* ⚠️ 用 EXISTS 而不是 JOIN:共享给多个人时 JOIN 会让同一条笔记出现多次。
|
||||||
|
*/
|
||||||
|
public const VIS_COND =
|
||||||
|
'(n.person_id = ? OR EXISTS (SELECT 1 FROM note_shares s
|
||||||
|
WHERE s.note_id = n.id AND s.person_id = ?))';
|
||||||
|
|
||||||
|
/** 共享权限的白名单(与 resources 同一套语义) */
|
||||||
|
public const PERMS = ['read', 'write'];
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// 读
|
// 读
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -81,12 +104,22 @@ final class Note
|
|||||||
int $limit = 0,
|
int $limit = 0,
|
||||||
int $offset = 0
|
int $offset = 0
|
||||||
): array {
|
): array {
|
||||||
|
/* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**:
|
||||||
|
「我写的」**或**「有一条指向我的共享记录」。
|
||||||
|
⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)——
|
||||||
|
v27 那个 `is_shared = 1`(共享给全家)已不再是判据,
|
||||||
|
升级时它被展开成逐人记录(见 Schema 的迁移)。
|
||||||
|
⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的
|
||||||
|
笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */
|
||||||
|
$vis = self::VIS_COND;
|
||||||
$sql = 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color,
|
$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
|
CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine
|
||||||
FROM notes n
|
FROM notes n
|
||||||
JOIN persons p ON p.id = n.person_id
|
JOIN persons p ON p.id = n.person_id
|
||||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
WHERE n.family_id = ? AND ' . $vis;
|
||||||
$bind = [$viewer, $familyId, $viewer];
|
// ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
|
||||||
|
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
|
||||||
|
$bind = [$viewer, $familyId, $viewer, $viewer];
|
||||||
|
|
||||||
$kw = trim($keyword);
|
$kw = trim($keyword);
|
||||||
if ($kw !== '') {
|
if ($kw !== '') {
|
||||||
@@ -123,8 +156,8 @@ final class Note
|
|||||||
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
|
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
|
||||||
{
|
{
|
||||||
$sql = 'SELECT COUNT(*) FROM notes n
|
$sql = 'SELECT COUNT(*) FROM notes n
|
||||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
WHERE n.family_id = ? AND ' . self::VIS_COND;
|
||||||
$bind = [$familyId, $viewer];
|
$bind = [$familyId, $viewer, $viewer];
|
||||||
$kw = trim($keyword);
|
$kw = trim($keyword);
|
||||||
if ($kw !== '') {
|
if ($kw !== '') {
|
||||||
$sql .= ' AND (n.title LIKE ?
|
$sql .= ' AND (n.title LIKE ?
|
||||||
@@ -162,10 +195,7 @@ final class Note
|
|||||||
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
|
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
|
||||||
);
|
);
|
||||||
foreach ($st->fetchAll() as $b) {
|
foreach ($st->fetchAll() as $b) {
|
||||||
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
|
$b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
|
||||||
$b['html'] = (string)$b['kind'] === 'text'
|
|
||||||
? Markdown::render((string)$b['content'])
|
|
||||||
: '';
|
|
||||||
$blocks[(int)$b['note_id']][] = $b;
|
$blocks[(int)$b['note_id']][] = $b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,9 +258,9 @@ final class Note
|
|||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
|
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
|
||||||
FROM notes n JOIN persons p ON p.id = n.person_id
|
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)'
|
WHERE n.id = ? AND n.family_id = ? AND ' . self::VIS_COND);
|
||||||
);
|
// ⚠️ VIS_COND 两个 `?`,$viewer 绑两遍(顺序:id / family / person_id / person_id)
|
||||||
$st->execute([$id, $familyId, $viewer]);
|
$st->execute([$id, $familyId, $viewer, $viewer]);
|
||||||
$r = $st->fetch();
|
$r = $st->fetch();
|
||||||
if (!$r) {
|
if (!$r) {
|
||||||
return null;
|
return null;
|
||||||
@@ -296,11 +326,7 @@ final class Note
|
|||||||
$st->execute([$noteId]);
|
$st->execute([$noteId]);
|
||||||
$rows = $st->fetchAll();
|
$rows = $st->fetchAll();
|
||||||
foreach ($rows as &$r) {
|
foreach ($rows as &$r) {
|
||||||
$kind = (string)$r['kind'];
|
$r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
|
||||||
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
|
|
||||||
$r['html'] = $kind === 'text'
|
|
||||||
? Markdown::render((string)$r['content'])
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
return $rows;
|
return $rows;
|
||||||
@@ -329,10 +355,10 @@ final class Note
|
|||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT t.tag, COUNT(DISTINCT n.id) AS n
|
'SELECT t.tag, COUNT(DISTINCT n.id) AS n
|
||||||
FROM note_tags t JOIN notes n ON n.id = t.note_id
|
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)
|
WHERE n.family_id = ? AND ' . self::VIS_COND . '
|
||||||
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
|
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
|
||||||
);
|
);
|
||||||
$st->execute([$familyId, $viewer]);
|
$st->execute([$familyId, $viewer, $viewer]);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,13 +395,15 @@ final class Note
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
|
||||||
|
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
|
||||||
|
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
|
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
|
||||||
VALUES (?,?,?,?,NOW(),NOW())'
|
VALUES (?,?,?,0,NOW(),NOW())'
|
||||||
)->execute([
|
)->execute([
|
||||||
$familyId, $personId,
|
$familyId, $personId,
|
||||||
mb_substr($title, 0, 200, 'UTF-8'),
|
mb_substr($title, 0, 200, 'UTF-8'),
|
||||||
!empty($d['is_shared']) ? 1 : 0,
|
|
||||||
]);
|
]);
|
||||||
$id = (int)$db->lastInsertId();
|
$id = (int)$db->lastInsertId();
|
||||||
self::writeBlocks($id, $blocks);
|
self::writeBlocks($id, $blocks);
|
||||||
@@ -386,15 +414,22 @@ final class Note
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。
|
* 改一条笔记。
|
||||||
* @return bool 是否改到了(改不到 = 不是我的/不存在)
|
*
|
||||||
|
* 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是
|
||||||
|
* 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。
|
||||||
|
* v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个
|
||||||
|
* 开关时的简化;现在有了逐人权限,就该按权限来。
|
||||||
|
* ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的,
|
||||||
|
* "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。
|
||||||
|
* @return bool 是否改到了(改不到 = 看不到 / 没有写权限)
|
||||||
*/
|
*/
|
||||||
public static function update(int $familyId, int $id, int $personId, array $d): bool
|
public static function update(int $familyId, int $id, int $personId, array $d): bool
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
$st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?');
|
// 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
|
||||||
$st->execute([$id, $familyId, $personId]);
|
$note = self::find($familyId, $id, $personId);
|
||||||
if (!$st->fetchColumn()) {
|
if (!$note || !self::canWrite($note, $personId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$title = trim((string)($d['title'] ?? ''));
|
$title = trim((string)($d['title'] ?? ''));
|
||||||
@@ -404,9 +439,11 @@ final class Note
|
|||||||
if ($title === '' && !$blocks) {
|
if ($title === '' && !$blocks) {
|
||||||
$title = '(空笔记)';
|
$title = '(空笔记)';
|
||||||
}
|
}
|
||||||
$db->prepare(
|
// ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。
|
||||||
'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]);
|
// 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。
|
||||||
|
$db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?')
|
||||||
|
->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]);
|
||||||
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
|
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
|
||||||
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
|
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
|
||||||
self::writeBlocks($id, $blocks);
|
self::writeBlocks($id, $blocks);
|
||||||
@@ -455,6 +492,95 @@ final class Note
|
|||||||
)->execute([$familyId]);
|
)->execute([$familyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 共享(v29 用户第 9 条)—— 与 App\Resource 同一套语义
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/** 这条笔记共享给了谁(带权限与对方的名字/头像) */
|
||||||
|
public static function shares(int $noteId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
|
||||||
|
FROM note_shares s JOIN persons p ON p.id = s.person_id
|
||||||
|
WHERE s.note_id = ? ORDER BY p.id'
|
||||||
|
);
|
||||||
|
$st->execute([$noteId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条笔记共享给了几个人的映射(列表页一次性取,避免每条一次查询) */
|
||||||
|
public static function shareCounts(array $noteIds): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter(array_map('intval', $noteIds)));
|
||||||
|
if (!$ids) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$in = implode(',', $ids);
|
||||||
|
$out = [];
|
||||||
|
foreach (Database::get()->query(
|
||||||
|
"SELECT note_id, COUNT(*) AS n FROM note_shares
|
||||||
|
WHERE note_id IN ($in) GROUP BY note_id"
|
||||||
|
)->fetchAll() as $r) {
|
||||||
|
$out[(int)$r['note_id']] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设共享。
|
||||||
|
*
|
||||||
|
* ⚠️ 作者本人**不进**共享表 —— 他本来就全能,
|
||||||
|
* 写进去只会让"共享给谁"的列表里冒出自己(同 Resource::setShare)。
|
||||||
|
*/
|
||||||
|
public static function setShare(int $noteId, int $ownerId, int $personId, string $perm): void
|
||||||
|
{
|
||||||
|
if ($personId <= 0 || $personId === $ownerId) {
|
||||||
|
self::dropShare($noteId, $personId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Database::get()->prepare(
|
||||||
|
'INSERT INTO note_shares (note_id, person_id, perm) VALUES (?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
||||||
|
)->execute([$noteId, $personId, in_array($perm, self::PERMS, true) ? $perm : 'read']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dropShare(int $noteId, int $personId): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('DELETE FROM note_shares WHERE note_id = ? AND person_id = ?')
|
||||||
|
->execute([$noteId, $personId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我对某条笔记的权限:'owner' / 'write' / 'read' / ''(看不到)。
|
||||||
|
*
|
||||||
|
* ⚠️ 这是**唯一**的权限判定(页面按钮、服务端写入校验都读它)——
|
||||||
|
* 两处各写一套的话,迟早出现"按钮没显示但接口能改"(或者反过来)。
|
||||||
|
*/
|
||||||
|
public static function permOf(array $note, int $viewer): string
|
||||||
|
{
|
||||||
|
if ((int)$note['person_id'] === $viewer) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT perm FROM note_shares WHERE note_id = ? AND person_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([(int)$note['id'], $viewer]);
|
||||||
|
$p = (string)($st->fetchColumn() ?: '');
|
||||||
|
return in_array($p, self::PERMS, true) ? $p : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 能改吗(作者或 write 权限)。删除**只限作者** —— 见 destroy()。 */
|
||||||
|
public static function canWrite(array $note, int $viewer): bool
|
||||||
|
{
|
||||||
|
$p = self::permOf($note, $viewer);
|
||||||
|
return $p === 'owner' || $p === 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function permWord(string $perm): string
|
||||||
|
{
|
||||||
|
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
|
||||||
|
}
|
||||||
|
|
||||||
/** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
|
/** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
|
||||||
public static function destroy(int $familyId, int $id, int $personId): void
|
public static function destroy(int $familyId, int $id, int $personId): void
|
||||||
{
|
{
|
||||||
@@ -498,13 +624,83 @@ final class Note
|
|||||||
// 内部:块 / 标签的清洗与写入
|
// 内部:块 / 标签的清洗与写入
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个块的渲染结果(HTML)。**全站唯一**的块渲染入口。
|
||||||
|
*
|
||||||
|
* 🔴 v29 第 8 条:清单块并入 Markdown 之后,这里要**同时认老数据**:
|
||||||
|
* 库里可能还躺着 `kind = 'list'` 的块(v27/v28 写的),
|
||||||
|
* 用户没编辑过它们 → 仍是 list → 必须照样渲染出列表,不能变成空白。
|
||||||
|
* ⚠️ 走同一套 `listToMarkdown` 转换(与 cleanBlocks 共用),
|
||||||
|
* 这样"老块在页面上"与"老库保存一次之后的文本"长得完全一样。
|
||||||
|
*
|
||||||
|
* @return string 图片块返回 ''(图片不用 HTML,由模板直接出 <img>)
|
||||||
|
*/
|
||||||
|
public static function blockHtml(string $kind, string $content): string
|
||||||
|
{
|
||||||
|
if ($kind === 'image') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($kind === 'list') {
|
||||||
|
// 历史清单块 → 转成 Markdown 列表再渲染(数据不丢,样子不变)
|
||||||
|
return Markdown::render(self::listToMarkdown($content));
|
||||||
|
}
|
||||||
|
return Markdown::render($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史清单块 → Markdown 文本的**对外**入口(模板把老块放进编辑器时用)。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么要暴露它:编辑态要把"库里的块"变成"编辑器里的文本",
|
||||||
|
* 而 list 块的转换逻辑只该有一份(`listToMarkdown`)。
|
||||||
|
* 模板直接调它,比在模板里再写一遍正则可靠得多。
|
||||||
|
* @param string $content 清单块的原始内容(每行一项,缩进表层级)
|
||||||
|
*/
|
||||||
|
public static function blockHtmlOut(string $content): string
|
||||||
|
{
|
||||||
|
return self::listToMarkdown($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史清单块 → Markdown 无序列表文本(v29 收掉清单块时用)。
|
||||||
|
*
|
||||||
|
* 约定(v27 定下的):每行一项,**行首缩进表示层级**。
|
||||||
|
* 这里按缩进深度换算成 Markdown 的缩进(同样表层级),最多 3 层 ——
|
||||||
|
* 更深的层级在 20px 的行高里已经看不出来了,硬转反而让源码难读。
|
||||||
|
*/
|
||||||
|
private static function listToMarkdown(string $content): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
foreach (preg_split('/\R/u', $content) ?: [] as $line) {
|
||||||
|
$raw = rtrim($line, " \t");
|
||||||
|
if (trim($raw) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$indent = strlen($raw) - strlen(ltrim($raw, " \t"));
|
||||||
|
$depth = min(3, (int)floor($indent / 2));
|
||||||
|
$lines[] = str_repeat(' ', $depth) . '- ' . trim($raw);
|
||||||
|
}
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清洗前端提交的块:
|
* 清洗前端提交的块:
|
||||||
* · kind 归一(只认白名单里的三种,其它一律当 text)
|
* · kind 归一(只认白名单里的两种,其它一律当 text)
|
||||||
|
* · **历史 list 块转成 Markdown 列表文本**(见下面的说明)
|
||||||
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
|
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
|
||||||
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
|
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
|
||||||
* · 上限 MAX_BLOCKS
|
* · 上限 MAX_BLOCKS
|
||||||
*
|
*
|
||||||
|
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||||
|
* 把段落做的完善一点就可以了。」
|
||||||
|
* ⇒ 块类型收成 `text` / `image` 两种。
|
||||||
|
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
|
||||||
|
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
|
||||||
|
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
|
||||||
|
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
|
||||||
|
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
|
||||||
|
* 而用户以后可以在它上面用 Markdown 继续写。
|
||||||
|
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
|
||||||
|
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
|
||||||
|
*
|
||||||
* @return array<int,array{kind:string,content:string}>
|
* @return array<int,array{kind:string,content:string}>
|
||||||
*/
|
*/
|
||||||
private static function cleanBlocks($raw): array
|
private static function cleanBlocks($raw): array
|
||||||
@@ -522,6 +718,13 @@ final class Note
|
|||||||
$kind = 'text';
|
$kind = 'text';
|
||||||
}
|
}
|
||||||
$content = (string)($b['content'] ?? '');
|
$content = (string)($b['content'] ?? '');
|
||||||
|
|
||||||
|
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
|
||||||
|
if ($kind === 'list') {
|
||||||
|
$content = self::listToMarkdown($content);
|
||||||
|
$kind = 'text';
|
||||||
|
}
|
||||||
|
|
||||||
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
|
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
|
||||||
$content = $kind === 'image'
|
$content = $kind === 'image'
|
||||||
? trim(preg_replace('/\s+/', '', $content) ?? '')
|
? trim(preg_replace('/\s+/', '', $content) ?? '')
|
||||||
|
|||||||
+110
-1
@@ -108,6 +108,22 @@ final class Schema
|
|||||||
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
|
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
|
||||||
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
|
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
|
||||||
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
|
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
|
||||||
|
* 29 → **微信读书数据落盘(用户 2026-10-08 第 4 条)**:新增 `weread_snapshots`
|
||||||
|
* (按 (人, 数据集) 存 JSON 快照)。动机是用户的原话:
|
||||||
|
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
|
||||||
|
* 微信读书的数据了。」
|
||||||
|
* ⇒ `notebooks()` / `readStats()` 在**页面渲染路径**上不再打网关,
|
||||||
|
* 只读快照;打接口只发生在同步时(手动 / 6 小时到期)。
|
||||||
|
* ⚠️ 快照有 `updated_at`,过期(默认 24 小时)仍会触发一次后台同步,
|
||||||
|
* 但**渲染永远不等它** —— 拿旧快照先渲染,同步结果下次访问可见。
|
||||||
|
* 30 → **笔记共享改成"逐人 + 权限"(用户 2026-10-08 第 9 条)**:新增
|
||||||
|
* `note_shares`(与 `resource_shares` 同构)。动机是用户的原话:
|
||||||
|
* 「笔记的共享,和其他的模块一样,选择共享人,选择是只读还是可以读写。和资源类似。」
|
||||||
|
* ⇒ 取消 v27 的"一个 `is_shared` 布尔(共享给全家)",
|
||||||
|
* 老数据升级时**展开成逐人记录**(给家里每个人一条 read)。
|
||||||
|
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
|
||||||
|
* 迁移是"一次性数据转换",不是"持续同步",
|
||||||
|
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||||
@@ -115,7 +131,7 @@ final class Schema
|
|||||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||||
*/
|
*/
|
||||||
public const VERSION = '28';
|
public const VERSION = '30';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||||
@@ -1008,6 +1024,62 @@ CREATE TABLE IF NOT EXISTS `person_tags` (
|
|||||||
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
|
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
|
||||||
|
SQL,
|
||||||
|
<<<'SQL'
|
||||||
|
/* 🔴 v29 用户第 4 条:**微信读书的数据落盘,页面只读本地**。
|
||||||
|
用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都
|
||||||
|
读取微信读书的数据了。」
|
||||||
|
⇒ 症结确实在这:`notebooks()` 与 `readStats()` 每次渲染都**实时打网关**,
|
||||||
|
一次读书页要打好几次外部请求(且官方接口本身就慢)。
|
||||||
|
实测口径:以前每个请求都要等外部接口返回,`SYNC_BUDGET` 只能限制"同步"那一段,
|
||||||
|
限不住"每次渲染都去读统计"那一段。
|
||||||
|
⇒ 做法:**一份按 (人, 数据集) 存的 JSON 快照**。
|
||||||
|
· 页面渲染只读这张表(本地查询,毫秒级);
|
||||||
|
· 真正打接口只发生在**同步**时(手动「立即同步」或 6 小时到期的自动同步);
|
||||||
|
· 同步成功后把相关快照一起刷新。
|
||||||
|
⚠️ 为什么用"一个 JSON 列"而不是把统计拆成多张表:
|
||||||
|
这些数据是**只读的远端结果**、字段完全由官方决定(说改就改),
|
||||||
|
本站从不按其中任何字段做查询。拆表=把别人的字段设计背到自己身上,
|
||||||
|
官方一变就要迁移。存 JSON = 官方怎么变都能原样兜住。
|
||||||
|
⚠️ 为什么不像封面那样塞进 `book`/`attachment`:它不是"我们的实体",
|
||||||
|
是一次外部调用的缓存,有明确的**时效性**(`updated_at`),
|
||||||
|
过期就该重新拉 —— 与"实体数据"的生命周期完全不同,混在一起会后患无穷。 */
|
||||||
|
CREATE TABLE IF NOT EXISTS `weread_snapshots` (
|
||||||
|
`person_id` INT UNSIGNED NOT NULL,
|
||||||
|
`k` VARCHAR(40) NOT NULL COMMENT '数据集名:notebooks / stats_overall / stats_weekly …',
|
||||||
|
`v` LONGTEXT DEFAULT NULL COMMENT '原样存下来的 JSON(官方字段,本站不解析、不索引)',
|
||||||
|
`updated_at` DATETIME NOT NULL COMMENT '这次快照是什么时候拉的(判过期用)',
|
||||||
|
PRIMARY KEY (`person_id`, `k`),
|
||||||
|
KEY `idx_ws_updated` (`updated_at`),
|
||||||
|
CONSTRAINT `fk_ws_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='微信读书的远端数据快照(v29:页面只读本地,不再每次打网关)'
|
||||||
|
SQL,
|
||||||
|
<<<'SQL'
|
||||||
|
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
|
||||||
|
选择是只读还是可以读写。和资源类似。」
|
||||||
|
⇒ 把 v27 那个"一个 is_shared 布尔(共享给全家)"升级成**逐人共享 + 权限**。
|
||||||
|
⚠️ 为什么不是"保留 is_shared,再加一张人表":
|
||||||
|
那样会出现两种共享语义并存("全家可见" + "给这几个人"),
|
||||||
|
判定优先级、列表怎么算、谁能看到 —— 全是边界问题。
|
||||||
|
统一成一种(逐人 + 权限)之后,判定只有一处:
|
||||||
|
`owner 本人 or 在某条 share 记录里`。
|
||||||
|
⚠️ `notes.is_shared` 列**保留但不再作为判据**(升级时把老的"1"展开成
|
||||||
|
"给全家每个人一条 read 记录",迁移完就没它的事了)。
|
||||||
|
保留列是为了老库升级时能读到它(见 migrateSharedNotes)。
|
||||||
|
⚠️ 与资源共享表**同构**(列名/枚举都一样):两处的权限语义要一致,
|
||||||
|
表结构一样,读代码的人一眼就知道"跟资源那套是一回事"。 */
|
||||||
|
CREATE TABLE IF NOT EXISTS `note_shares` (
|
||||||
|
`note_id` INT UNSIGNED NOT NULL,
|
||||||
|
`person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(**不含**作者 —— 自己写的本来就能改)',
|
||||||
|
`perm` ENUM('read','write') NOT NULL DEFAULT 'read' COMMENT 'read=只读 / write=可读写',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`note_id`, `person_id`),
|
||||||
|
KEY `idx_ns_person` (`person_id`),
|
||||||
|
CONSTRAINT `fk_ns_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)'
|
||||||
SQL,
|
SQL,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1894,6 +1966,43 @@ SQL);
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
|
||||||
|
选择是只读还是可以读写。和资源类似。」
|
||||||
|
⇒ 把 v27 的"`is_shared = 1`(共享给全家)"展开成**逐人记录**。
|
||||||
|
怎么做:对每一条 `is_shared = 1` 的笔记,给**家里其他每个人**写一条
|
||||||
|
`perm = 'read'`(读得到,但不能改 —— 原来的语义就是"全家可见",
|
||||||
|
并没有给过他们改的权限,迁移必须**保持原语义**,不能顺手扩大权限)。
|
||||||
|
⚠️ 排除作者自己:作者本来就全能,给自己写一条共享记录会污染"共享给谁"的列表。
|
||||||
|
⚠️ 一次性哨兵(`note_shares_migrated_v29`)—— 理由与 v28 那次完全一样:
|
||||||
|
迁移是"一次性数据转换",不是"持续同步"。否则用户把某人从共享里去掉后,
|
||||||
|
下次启动又被灌回来(本项目已栽过 4 次,见 v28 的详细记录)。
|
||||||
|
⚠️ 这个迁移**必须在 note_shares 建好之后**跑(外键指向 notes/persons,
|
||||||
|
两张表都在 statements() 里先建出来了)。 */
|
||||||
|
self::tryStep('笔记共享展开成逐人记录(全家可见 → 逐人只读)', static function () use ($db) {
|
||||||
|
$flag = 'note_shares_migrated_v29';
|
||||||
|
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
|
||||||
|
if ((int)$has > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self::hasTable($db, 'note_shares')) {
|
||||||
|
// 只处理"曾经标过共享"的笔记;逐人展开,权限一律 read(保持原语义)
|
||||||
|
$db->exec(
|
||||||
|
"INSERT IGNORE INTO `note_shares` (note_id, person_id, perm)
|
||||||
|
SELECT n.id, p.id, 'read'
|
||||||
|
FROM `notes` n
|
||||||
|
JOIN `persons` p ON p.family_id = n.family_id
|
||||||
|
WHERE n.is_shared = 1
|
||||||
|
AND p.id <> n.person_id
|
||||||
|
AND p.kind = 'member'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
|
||||||
|
if ($fid > 0) {
|
||||||
|
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
|
||||||
|
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||||
if (self::$stepErrors) {
|
if (self::$stepErrors) {
|
||||||
|
|||||||
+157
-1
@@ -91,6 +91,22 @@ final class WeRead
|
|||||||
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
|
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
|
||||||
public const SYNC_MAX_BOOKS = 25;
|
public const SYNC_MAX_BOOKS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照的**保鲜期**(秒)—— 默认 24 小时。
|
||||||
|
*
|
||||||
|
* 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘,
|
||||||
|
* 这样就不用每次都读取微信读书的数据了。」
|
||||||
|
*
|
||||||
|
* ⇒ 落盘之后,"多久算旧"就成了新问题:
|
||||||
|
* · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘;
|
||||||
|
* · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。
|
||||||
|
* 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的),
|
||||||
|
* 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。
|
||||||
|
* ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。
|
||||||
|
* 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。
|
||||||
|
*/
|
||||||
|
public const SNAP_TTL = 86400;
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// Key 的存取(加密)
|
// Key 的存取(加密)
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -568,7 +584,8 @@ final class WeRead
|
|||||||
return !empty($up['ok']) ? (string)$up['file'] : '';
|
return !empty($up['ok']) ? (string)$up['file'] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
/**
|
||||||
|
* 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
||||||
private static function safeBase(string $s): string
|
private static function safeBase(string $s): string
|
||||||
{
|
{
|
||||||
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
|
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
|
||||||
@@ -576,6 +593,128 @@ final class WeRead
|
|||||||
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8');
|
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// v29 用户第 4 条:**本地快照优先**的取数入口
|
||||||
|
// ================================================================
|
||||||
|
//
|
||||||
|
// 规矩(只此一条,别处照做):
|
||||||
|
// · **渲染路径**一律调 `*Cached()`,它永不联网;
|
||||||
|
// · **同步路径**(手动「立即同步」/ 6 小时到期)才调真正的 `notebooks()`/`readStats()`,
|
||||||
|
// 拿到结果后用 `snapNotebooks()` / `snapStats()` 落盘。
|
||||||
|
//
|
||||||
|
// ⚠️ 缓存里**没有**(第一次用、刚换 Key)时怎么办:
|
||||||
|
// 这时页面没有任何数据可显示,只能真去打一次 —— 但那是"首次",不是常态。
|
||||||
|
// 之后的每一次访问都命中本地,这就是用户要的"落盘,不用每次去读"。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有笔记的书清单(**优先读本地快照**)。
|
||||||
|
*
|
||||||
|
* @param bool $allowFetch 本地没有时,是否允许真的去联网取(默认允许)
|
||||||
|
* @return array{ok:bool,books:array,error:string,cached:bool,updated_at:int}
|
||||||
|
*/
|
||||||
|
public static function notebooksCached(int $personId, bool $allowFetch = true): array
|
||||||
|
{
|
||||||
|
$snap = WeReadCache::get($personId, WeReadCache::K_NOTEBOOKS);
|
||||||
|
if ($snap['ok'] && is_array($snap['data'])) {
|
||||||
|
return ['ok' => true, 'books' => (array)($snap['data']['books'] ?? []),
|
||||||
|
'error' => '', 'cached' => true, 'updated_at' => (int)$snap['updated_at']];
|
||||||
|
}
|
||||||
|
if (!$allowFetch) {
|
||||||
|
return ['ok' => true, 'books' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
|
||||||
|
}
|
||||||
|
$r = self::notebooks($personId);
|
||||||
|
if ($r['ok']) {
|
||||||
|
self::snapNotebooks($personId, (array)$r['books']);
|
||||||
|
}
|
||||||
|
return ['ok' => (bool)$r['ok'], 'books' => (array)($r['books'] ?? []),
|
||||||
|
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把书清单写进快照 */
|
||||||
|
public static function snapNotebooks(int $personId, array $books): void
|
||||||
|
{
|
||||||
|
WeReadCache::put($personId, WeReadCache::K_NOTEBOOKS, ['books' => $books]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读统计(**优先读本地快照**,按 mode 各存一份) */
|
||||||
|
public static function readStatsCached(int $personId, string $mode = 'overall',
|
||||||
|
bool $allowFetch = true): array
|
||||||
|
{
|
||||||
|
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
|
||||||
|
$mode = 'overall';
|
||||||
|
}
|
||||||
|
$key = WeReadCache::statsKey($mode);
|
||||||
|
$snap = WeReadCache::get($personId, $key);
|
||||||
|
if ($snap['ok'] && is_array($snap['data'])) {
|
||||||
|
return ['ok' => true, 'data' => (array)$snap['data'], 'error' => '',
|
||||||
|
'cached' => true, 'updated_at' => (int)$snap['updated_at']];
|
||||||
|
}
|
||||||
|
if (!$allowFetch) {
|
||||||
|
return ['ok' => true, 'data' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
|
||||||
|
}
|
||||||
|
$r = self::readStats($personId, $mode);
|
||||||
|
if ($r['ok']) {
|
||||||
|
self::snapStats($personId, $mode, (array)$r['data']);
|
||||||
|
}
|
||||||
|
return ['ok' => (bool)$r['ok'], 'data' => (array)($r['data'] ?? []),
|
||||||
|
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把统计写进快照 */
|
||||||
|
public static function snapStats(int $personId, string $mode, array $data): void
|
||||||
|
{
|
||||||
|
WeReadCache::put($personId, WeReadCache::statsKey($mode), $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照的"数据更新于"时间(页面上用;0 = 从没同步过) */
|
||||||
|
public static function snapshotAt(int $personId): int
|
||||||
|
{
|
||||||
|
return WeReadCache::updatedAt($personId, WeReadCache::K_NOTEBOOKS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int,bool> 本次请求已经排过后台刷新的(避免重复注册 shutdown 钩子) */
|
||||||
|
private static array $bgQueued = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排一次"**响应发完之后**再去同步"。
|
||||||
|
*
|
||||||
|
* 🔴 v29 用户第 4 条的另一半:光把数据落盘还不够 ——
|
||||||
|
* 原来那段自动同步挂在**渲染之前**(`maybeSync` 在 `View::render` 上面),
|
||||||
|
* 所以"6 小时到期的那一次"仍然要等它跑完才出页面。
|
||||||
|
* 落盘解决了"每次都要去读",但没解决"那一 次要等"。
|
||||||
|
* ⇒ 照 `Holiday::queueBackgroundFetch()` 的成熟做法:
|
||||||
|
* `fastcgi_finish_request()` 把响应**立刻发给浏览器**,
|
||||||
|
* 之后脚本继续跑同步、写快照,下次访问自然就是新的。
|
||||||
|
*
|
||||||
|
* ⚠️ php-fpm 才有 `fastcgi_finish_request`;`php -S`(本地)没有,
|
||||||
|
* 那就是"同步仍在请求内发生" —— 与改造前一致,不会更差。
|
||||||
|
* ⚠️ 用 `register_shutdown_function` 而不是在这里直接调:
|
||||||
|
* 必须等**页面已经渲染完**(输出都发出去)才切断,否则会把页面截断。
|
||||||
|
* ⚠️ 同一个请求里只排一次(`$bgQueued`):读书页会连着调好几次取数。
|
||||||
|
*/
|
||||||
|
public static function queueBackgroundSync(int $familyId, int $personId): void
|
||||||
|
{
|
||||||
|
if ($personId <= 0 || $familyId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isset(self::$bgQueued[$personId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$bgQueued[$personId] = true;
|
||||||
|
register_shutdown_function(static function () use ($familyId, $personId): void {
|
||||||
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
|
fastcgi_finish_request();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 走到这里时"间隔到没到"要重新判一次(queue 时不判、执行时判):
|
||||||
|
// 期间可能已经有别的请求同步过了,那就别再打一次接口。
|
||||||
|
self::maybeSync($familyId, $personId, false);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 后台任务绝不能把响应打挂(此时响应早已发完,报错只会进日志)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
|
/** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
|
||||||
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
|
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
|
||||||
{
|
{
|
||||||
@@ -886,6 +1025,23 @@ final class WeRead
|
|||||||
$newNotes += (int)$r['added'];
|
$newNotes += (int)$r['added'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 🔴 v29 用户第 4 条:同步完把**书清单与统计**一起落盘。
|
||||||
|
这一步是"落盘"方案的核心收益所在 —— 之后页面渲染只读这张快照,
|
||||||
|
不再每次去问官方要(用户原话:「不用每次都读取微信读书的数据了」)。
|
||||||
|
⚠️ 放在这里(同步的末尾)而不是"每次保存某本书时":
|
||||||
|
书清单是一份**整体**,逐本保存会让"某几次成功、某一次失败"
|
||||||
|
留下一份拼凑的清单;这里存的是"这一轮真实扫到的完整清单"。
|
||||||
|
⚠️ 即使本轮因预算中途停下,也要落盘:清单本身就是 notebooks() 的
|
||||||
|
原始返回(完整、未被裁剪),"扫了几本"是另一件事(那是书架的补全进度)。
|
||||||
|
⚠️ 统计与清单都**只在自己成功时**才覆盖:拉失败就保留旧快照,
|
||||||
|
宁可是旧数据,也不要把页面变成空的。 */
|
||||||
|
if (!empty($nb['books'])) {
|
||||||
|
self::snapNotebooks($personId, (array)$nb['books']);
|
||||||
|
}
|
||||||
|
$st = self::readStats($personId, 'overall');
|
||||||
|
if (!empty($st['ok'])) {
|
||||||
|
self::snapStats($personId, 'overall', (array)$st['data']);
|
||||||
|
}
|
||||||
return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
|
return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
|
||||||
'covers' => $newCovers, 'scanned' => $scanned];
|
'covers' => $newCovers, 'scanned' => $scanned];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 微信读书远端数据的**本地快照**(v29 用户 2026-10-08 第 4 条)。
|
||||||
|
*
|
||||||
|
* 用户原话:
|
||||||
|
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
|
||||||
|
* 微信读书的数据了。」
|
||||||
|
*
|
||||||
|
* ────────────────────────────────────────────────────────────────
|
||||||
|
* 为什么单开一个类、而不是塞进 `WeRead`:
|
||||||
|
* `WeRead` 的头部写得很清楚 ——「这个类**只负责取数据**,不碰数据库」。
|
||||||
|
* 那条约定是有价值的(网络与存储分开,测试可以只测取数不连外网)。
|
||||||
|
* 落盘是**另一件事**,所以放另一个类;`WeRead` 保持"干净的网络层"。
|
||||||
|
*
|
||||||
|
* 为什么是"JSON 快照"而不是"拆成表":
|
||||||
|
* 这些数据是**只读的远端结果**,字段完全由官方决定(说改就改),
|
||||||
|
* 本站从不按其中任何字段做查询、排序、JOIN。
|
||||||
|
* 拆表 = 把别人的字段设计背到自己身上,官方一变就要写迁移;
|
||||||
|
* 存 JSON = 官方怎么变都能原样兜住(渲染端按需取,取不到就当空)。
|
||||||
|
*
|
||||||
|
* 为什么渲染路径**永远不等**刷新(关键设计):
|
||||||
|
* `get()` 只读本地,永不联网。若快照过期,调用方可以"顺手"触发一次刷新,
|
||||||
|
* 但**必须先拿旧快照把页面渲染出来**,刷新结果留到下次访问可见。
|
||||||
|
* 反面做法是"过期就同步完再渲染" —— 那就又回到"点进来要等很久",
|
||||||
|
* 也就是用户这次报的那个问题。**"数据新鲜"永远排在"页面能立刻打开"之后。**
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class WeReadCache
|
||||||
|
{
|
||||||
|
/** 快照的数据集名 —— 只在这里登记,别处一律用常量,避免拼错字符串 */
|
||||||
|
public const K_NOTEBOOKS = 'notebooks';
|
||||||
|
|
||||||
|
/** 阅读统计(按 mode 各存一份:overall / weekly / monthly / annually) */
|
||||||
|
public static function statsKey(string $mode): string
|
||||||
|
{
|
||||||
|
return 'stats_' . $mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一份快照。
|
||||||
|
*
|
||||||
|
* @param bool $freshOnly true = 过期就当没有(返回 null)——
|
||||||
|
* 给那些"宁可不显示也不要旧数据"的场景用
|
||||||
|
* @return array{ok:bool,data:mixed,updated_at:int,stale:bool}
|
||||||
|
* ok=false 表示**本地没有**(从没同步过)
|
||||||
|
*/
|
||||||
|
public static function get(int $personId, string $key, bool $freshOnly = false): array
|
||||||
|
{
|
||||||
|
$miss = ['ok' => false, 'data' => null, 'updated_at' => 0, 'stale' => true];
|
||||||
|
if ($personId <= 0 || $key === '') {
|
||||||
|
return $miss;
|
||||||
|
}
|
||||||
|
$db = Database::get();
|
||||||
|
try {
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT v, UNIX_TIMESTAMP(updated_at) AS ts
|
||||||
|
FROM weread_snapshots WHERE person_id = ? AND k = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$personId, $key]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 表还没建(升级中)→ 当作"没有快照",绝不让缓存层把页面打挂
|
||||||
|
return $miss;
|
||||||
|
}
|
||||||
|
if (!$row) {
|
||||||
|
return $miss;
|
||||||
|
}
|
||||||
|
$ts = (int)($row['ts'] ?? 0);
|
||||||
|
$stale = (time() - $ts) > WeRead::SNAP_TTL;
|
||||||
|
if ($freshOnly && $stale) {
|
||||||
|
return $miss;
|
||||||
|
}
|
||||||
|
$data = json_decode((string)($row['v'] ?? ''), true);
|
||||||
|
return ['ok' => true, 'data' => $data, 'updated_at' => $ts, 'stale' => $stale];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写一份快照(覆盖旧的)。失败静默 —— 缓存写不进去不该影响功能。 */
|
||||||
|
public static function put(int $personId, string $key, $data): void
|
||||||
|
{
|
||||||
|
if ($personId <= 0 || $key === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare(
|
||||||
|
'INSERT INTO weread_snapshots (person_id, k, v, updated_at)
|
||||||
|
VALUES (?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)'
|
||||||
|
);
|
||||||
|
$st->execute([$personId, $key, $json]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 静默(缓存是加速手段,不是功能本体)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最后一次落盘是什么时候(页面上显示"数据更新于 …") */
|
||||||
|
public static function updatedAt(int $personId, string $key): int
|
||||||
|
{
|
||||||
|
return (int)self::get($personId, $key)['updated_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个人有没有**任何**快照(判断"是不是第一次用") */
|
||||||
|
public static function hasAny(int $personId): bool
|
||||||
|
{
|
||||||
|
if ($personId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT COUNT(*) FROM weread_snapshots WHERE person_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$personId]);
|
||||||
|
return (int)$st->fetchColumn() > 0;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉一个人的全部快照(换 Key / 停用自动同步时用) */
|
||||||
|
public static function forget(int $personId): void
|
||||||
|
{
|
||||||
|
if ($personId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Database::get()->prepare('DELETE FROM weread_snapshots WHERE person_id = ?')
|
||||||
|
->execute([$personId]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-14
@@ -1089,8 +1089,18 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
以下都是 2026-09-30 那一批反馈加的东西
|
以下都是 2026-09-30 那一批反馈加的东西
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */
|
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉)
|
||||||
.sr-only {
|
🔴 v29 用户第 2 条:「任务完成复选框的提示文字还在」——
|
||||||
|
真根因:`_task_check.php` 与任务看板用的是 `class="visually-hidden"`,
|
||||||
|
而**本文件里从来没有这个类**(项目一直只定义 `.sr-only`)。
|
||||||
|
CSS 里查不到的类 = 没有任何样式 → 那段文字是**普通 inline span,直接显示出来**,
|
||||||
|
就贴在复选框旁边(用户看到的就是「标记完成:待办」)。
|
||||||
|
⇒ 修法:把两个名字指向同一条规则(两种写法都认)。
|
||||||
|
为什么不是"把模板改成 sr-only 了事":这个类名在下游(别的模板、以后新写的)
|
||||||
|
同样可能被用到,名字不再是隐患;而只改调用点的话,下次谁再写
|
||||||
|
`visually-hidden` 就又漏了。这里是"给能力补上定义",不是"给某一处打补丁"。*/
|
||||||
|
.sr-only,
|
||||||
|
.visually-hidden {
|
||||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||||
}
|
}
|
||||||
@@ -4611,17 +4621,23 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
|||||||
.roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; }
|
.roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; }
|
||||||
.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; }
|
.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; }
|
||||||
|
|
||||||
/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ----
|
/* ---- 平铺列表:**仍然单列**(只是不再有"栏"的分隔线)----
|
||||||
⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、
|
🔴🔴 v29 用户第 3 条:「名册页面完全错乱」—— 这个 bug 是 v28 **我自己引入的**。
|
||||||
窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。
|
v28 我把这里写成了多列 grid(`repeat(auto-fill, minmax(320px,1fr))`),
|
||||||
用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */
|
真实后果有两个,都实测到了:
|
||||||
.roster.is-flat .people-grid {
|
① 卡片上有 `content-visibility: auto` + `contain-intrinsic-size: auto 68px`
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
(v24 为"千人名册不卡死"加的)。它在**单列流**里是安全的,
|
||||||
gap: 8px;
|
一旦卡片变成 grid item,屏外卡片的"估算高度"与网格轨道互相打架,
|
||||||
}
|
布局当场乱掉;
|
||||||
@media (max-width: 720px) {
|
② 每列只剩 ~339px(1280 宽 ÷ 3),而卡片里是
|
||||||
.roster.is-flat .people-grid { grid-template-columns: 1fr; }
|
[拖柄][头像 52px + 姓名/徽标][标签+编辑+删除 三个按钮 ≈213px] ——
|
||||||
}
|
实测 `person-main` 只分到 **106px**(含 52px 头像 → 姓名区仅 42px),
|
||||||
|
姓名、徽标全被挤碎,动作区还会折行。
|
||||||
|
⇒ 修法:**回到单列**(`.people-grid` 本来就是单列 flex,见文件上方 328 行),
|
||||||
|
把 v28 那条 grid 规则整个删掉。名册人数从 3 到 1000+ 都是单列,
|
||||||
|
与"分栏时代每一栏的卡片"同宽 —— 那才是这套卡片的原始设计宽度。
|
||||||
|
⚠️ 别再用"多列网格"来塞名册卡片:它的内容是**横向三段式**的,
|
||||||
|
天生吃宽度;窄了就一定碎。 */
|
||||||
|
|
||||||
/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」
|
/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」
|
||||||
原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。
|
原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。
|
||||||
@@ -4756,8 +4772,16 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
|||||||
.note-ex-empty { color: var(--faint); font-style: italic; }
|
.note-ex-empty { color: var(--faint); font-style: italic; }
|
||||||
/* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */
|
/* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */
|
||||||
.note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); }
|
.note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); }
|
||||||
|
/* 🔴 v29 第 6 条:「笔记,首页显示的时候,里面的图片,和行程的类似,
|
||||||
|
把图片按照比例缩放到固定的高度或者宽度,放在下面就行了。」
|
||||||
|
⇒ 固定高度 + **等比完整显示**(contain),不再 cover 裁切。
|
||||||
|
⚠️ 从 cover 改 contain 是有意的取舍:
|
||||||
|
cover 能让每张图铺满、看着"实",但**看不到完整内容**(横图两端被切掉)——
|
||||||
|
用户要的是"按比例缩小放在下面",那就是要看到**整张**。
|
||||||
|
代价是宽高比与格子不一致时两侧留浅底,所以给个底色让留白显得是有意的。 */
|
||||||
.note-thumb img {
|
.note-thumb img {
|
||||||
width: 100%; height: 88px; object-fit: cover; display: block;
|
width: 100%; height: 110px; object-fit: contain; display: block;
|
||||||
|
background: #F6F4F0;
|
||||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||||
}
|
}
|
||||||
.note-card .note-tags-inline { margin-top: auto; padding-top: 8px; flex: 0 0 auto; }
|
.note-card .note-tags-inline { margin-top: auto; padding-top: 8px; flex: 0 0 auto; }
|
||||||
@@ -4872,3 +4896,90 @@ h5.md-h, h6.md-h { font-size: 14px; }
|
|||||||
/* 姓名那一栏允许收缩并截断,否则长名字也会顶破 */
|
/* 姓名那一栏允许收缩并截断,否则长名字也会顶破 */
|
||||||
.pname { overflow-wrap: anywhere; }
|
.pname { overflow-wrap: anywhere; }
|
||||||
}
|
}
|
||||||
|
/* 🔴 v29 第 3 条(窄屏半边):360px 下卡片只有 328px,
|
||||||
|
[拖柄 24][头像 52 + 姓名][标签+编辑+删除 ≈197] 硬塞在一行 → 姓名区只剩 ~40px。
|
||||||
|
⇒ 窄屏把动作区**整体折到第二行**(卡片改纵向),姓名那一行就宽裕了。
|
||||||
|
用 flex-direction: column 而不是给动作区定宽:后者的宽度随标签数变化,
|
||||||
|
定了也会被长标签顶开(v28 就是这么栽的)。 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.person { flex-wrap: wrap; }
|
||||||
|
.person .person-main { flex: 1 1 calc(100% - 40px); }
|
||||||
|
.person-actions {
|
||||||
|
flex: 1 1 100%; justify-content: flex-start;
|
||||||
|
padding: 0 10px 10px 34px; /* 左边缩进到与姓名对齐(躲开拖柄) */
|
||||||
|
}
|
||||||
|
.person-tag-btn { max-width: 46vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
v29 用户第 7 / 8 / 9 条:笔记 —— 阅读态 / 工具条 / 斜线命令 / 图片调大小
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- 阅读态(第 8 条:点进来默认是"看")----
|
||||||
|
与编辑态的区别要**一眼看得出**:没有虚线框、没有按钮,就是一篇排好版的文档。 */
|
||||||
|
.note-read { max-width: 820px; }
|
||||||
|
.note-read-title { margin: 0 0 6px; font-size: 22px; font-weight: 600; color: var(--ink); }
|
||||||
|
.note-read-tags { margin: 0 0 14px; }
|
||||||
|
.note-read-body { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.note-read-block { font-size: 15px; line-height: 1.8; color: var(--ink); }
|
||||||
|
/* 图片:默认满宽,可调(内联 style 给百分比);圆角与卡片一致 */
|
||||||
|
.note-read-fig { margin: 0; }
|
||||||
|
.note-read-fig img {
|
||||||
|
max-width: 100%; height: auto; display: block;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
.note-read-fig a { display: inline-block; max-width: 100%; }
|
||||||
|
.note-read-h2 {
|
||||||
|
margin: 0 0 8px; font-size: 14px; font-weight: 600; color: var(--muted);
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
/* 附件区:一条细线把它与正文分开("在最下方"要看得出来是**另一块**) */
|
||||||
|
.note-read-files { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--line-2); }
|
||||||
|
.note-read .note-share-now { margin-top: 16px; }
|
||||||
|
|
||||||
|
/* ---- 编辑态:Markdown 工具条(第 7 条"要么有工具条")---- */
|
||||||
|
.note-cmdbar {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
||||||
|
padding: 6px 8px; margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||||
|
background: #FDFCFA;
|
||||||
|
}
|
||||||
|
.note-cmdbar .cmd {
|
||||||
|
min-width: 28px; height: 26px; padding: 0 7px;
|
||||||
|
border: 1px solid var(--line); border-radius: 5px; background: #fff;
|
||||||
|
color: var(--ink); cursor: pointer; font-size: 13px; line-height: 1;
|
||||||
|
}
|
||||||
|
.note-cmdbar .cmd:hover { border-color: var(--brand-2); color: var(--brand-ink); background: var(--brand-soft, #FDF3EC); }
|
||||||
|
.note-cmdbar .cmd-sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
|
||||||
|
.note-cmdbar .note-live { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ---- 斜线命令菜单(第 7 条"要么有代码提示")----
|
||||||
|
⚠️ 绝对定位在**块内**(不是 body):跟着块走,块删除/移动时不会留孤儿菜单。 */
|
||||||
|
.note-block-body { position: relative; }
|
||||||
|
.slash-menu {
|
||||||
|
position: absolute; left: 0; top: 100%; z-index: 30; display: none;
|
||||||
|
min-width: 190px; max-height: 220px; overflow-y: auto;
|
||||||
|
background: #fff; border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||||
|
box-shadow: var(--shadow); padding: 4px;
|
||||||
|
}
|
||||||
|
.slash-menu.is-on { display: block; }
|
||||||
|
.slash-item {
|
||||||
|
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||||
|
padding: 5px 8px; border: 0; border-radius: 5px; background: transparent;
|
||||||
|
cursor: pointer; text-align: left; font-size: 13px; color: var(--ink);
|
||||||
|
}
|
||||||
|
.slash-item b { color: var(--brand); font-family: ui-monospace, Consolas, monospace; }
|
||||||
|
.slash-item span { color: var(--muted); font-size: 12px; }
|
||||||
|
.slash-item:hover, .slash-item.is-on { background: var(--brand-soft, #FDF3EC); }
|
||||||
|
|
||||||
|
/* ---- 图片块:尺寸档位(第 8 条"图文混排时,需要可以调整大小")---- */
|
||||||
|
.note-img-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.note-img-sizes { display: inline-flex; gap: 3px; margin-left: auto; }
|
||||||
|
.note-img-size {
|
||||||
|
padding: 2px 7px; border: 1px solid var(--line); border-radius: 5px;
|
||||||
|
background: #fff; color: var(--muted); cursor: pointer; font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.note-img-size:hover { border-color: var(--brand-2); color: var(--brand-ink); }
|
||||||
|
.note-img-size.is-on { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); color: var(--brand-ink); }
|
||||||
|
/* 图上标着当前档位,编辑器里一眼能看出"这张被缩过了" */
|
||||||
|
.note-block[data-kind="image"] .note-block-img { transition: width .12s ease; }
|
||||||
|
|||||||
+320
-62
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。
|
* 个人笔记 · 分块编辑器(v27 起;v29 用户 2026-10-08 第 7/8 条大改)。
|
||||||
*
|
*
|
||||||
* 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
* 只做这几件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
||||||
* ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea)
|
* ① 把「块」渲染成一列可编辑行(**段落 textarea / 图片卡** —— v29 起没有清单块了)
|
||||||
* ② 增(+段落 / +图片 / +清单)、删、上移、下移
|
* ② 增(+段落 / +图片)、删、上移、下移
|
||||||
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
|
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
|
||||||
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
|
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
|
||||||
|
* ⑤ 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ **斜线命令**(行首打 `/`)
|
||||||
|
* ⑥ 🔴 v29 第 8 条:图片可以**调大小**(宽度 25/50/75/100%)
|
||||||
*
|
*
|
||||||
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
|
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
|
||||||
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
|
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
|
||||||
@@ -15,6 +17,10 @@
|
|||||||
*
|
*
|
||||||
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
|
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
|
||||||
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
|
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
|
||||||
|
*
|
||||||
|
* 🔴 v29 第 8 条:**清单块去掉**(用户原话「清单功能不要了吧,markdown 里面
|
||||||
|
* 包含这个功能」)。所以 `+清单` 按钮与 list 分支都没了 ——
|
||||||
|
* 想列清单就在段落里写 `- 项`(Markdown),渲染出来一样是列表。
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -29,13 +35,70 @@
|
|||||||
// 上传目标(哪个图片块在等这次上传的结果)
|
// 上传目标(哪个图片块在等这次上传的结果)
|
||||||
var pendingBlock = null;
|
var pendingBlock = null;
|
||||||
|
|
||||||
|
// media 出口的基址由模板注入(页面上的 `data-media-base`)——
|
||||||
|
// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。
|
||||||
|
var mediaBase = (form.getAttribute('data-media-base')
|
||||||
|
|| document.body.getAttribute('data-media-base')
|
||||||
|
|| 'media.php?k=');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 🔴 v29 第 7 条:Markdown 命令表(**工具条与斜线菜单共用这一份**)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// ⚠️ 为什么只有一份:如果工具条和斜线各写一套,迟早出现"按钮能插、
|
||||||
|
// 斜线插不了"或者"两处语法不一样"(这正是用户抱怨的"要记住所有命令")。
|
||||||
|
// 每个命令:wrap = 用来包住选中文本(含 %s),或 insert = 无选中时插的模板。
|
||||||
|
var CMDS = {
|
||||||
|
b: { word: '粗体', keys: ['bold', 'b'], wrap: '**%s**', tip: '加粗' },
|
||||||
|
i: { word: '斜体', keys: ['italic', 'i'], wrap: '*%s*', tip: '斜体' },
|
||||||
|
h2: { word: '标题', keys: ['h2', 'head'], wrap: '## %s', tip: '小标题' },
|
||||||
|
ul: { word: '列表', keys: ['ul', 'list'], wrap: '- %s', tip: '列表项' },
|
||||||
|
quote: { word: '引用', keys: ['quote', 'q'], wrap: '> %s', tip: '引用' },
|
||||||
|
code: { word: '代码', keys: ['code'], wrap: '```\n%s\n```', tip: '代码块' },
|
||||||
|
link: { word: '链接', keys: ['link', 'url'], wrap: '[%s](https://)', tip: '链接' },
|
||||||
|
img: { word: '图片', keys: ['img'], insert: '+ 图片(点左侧「+ 图片」按钮选文件)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 在 textarea 的**光标处**套上/插入一段 Markdown。 */
|
||||||
|
function applyCmd(ta, key) {
|
||||||
|
var c = CMDS[key];
|
||||||
|
if (!ta || !c) { return; }
|
||||||
|
var v = ta.value;
|
||||||
|
var s = ta.selectionStart || 0;
|
||||||
|
var e = ta.selectionEnd || 0;
|
||||||
|
var sel = v.slice(s, e);
|
||||||
|
var out;
|
||||||
|
|
||||||
|
if (c.insert) {
|
||||||
|
out = c.insert;
|
||||||
|
ta.value = v.slice(0, s) + out + v.slice(e);
|
||||||
|
ta.selectionStart = ta.selectionEnd = s + out.length;
|
||||||
|
} else if (sel !== '') {
|
||||||
|
// 有选中 → 包住它(多行时**逐行**套,这样"选中三行 → 列表"会得到三个列表项)
|
||||||
|
out = c.wrap.indexOf('%s') >= 0
|
||||||
|
? c.wrap.replace('%s', sel.indexOf('\n') >= 0
|
||||||
|
? sel.split('\n').map(function (l) { return l; }).join('\n')
|
||||||
|
: sel)
|
||||||
|
: sel + c.wrap;
|
||||||
|
ta.value = v.slice(0, s) + out + v.slice(e);
|
||||||
|
ta.selectionStart = s;
|
||||||
|
ta.selectionEnd = s + out.length;
|
||||||
|
} else {
|
||||||
|
// 没选中 → 插模板,光标落在 %s 处(用户接着打内容)
|
||||||
|
out = c.wrap;
|
||||||
|
var at = out.indexOf('%s');
|
||||||
|
ta.value = v.slice(0, s) + out + v.slice(e);
|
||||||
|
ta.selectionStart = ta.selectionEnd = s + (at >= 0 ? at : out.length);
|
||||||
|
}
|
||||||
|
ta.focus();
|
||||||
|
// 输入事件要**手动触发**:程序化改 value 浏览器不会发 input,
|
||||||
|
// 而预览与序列化都挂在那个事件上(不触发就会出现"插了但预览没变")。
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 渲染一块
|
// 渲染一块
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// media 出口的基址由模板注入(页面上的 `data-media-base`)——
|
|
||||||
// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。
|
|
||||||
var mediaBase = (document.body.getAttribute('data-media-base') || 'media.php?k=');
|
|
||||||
|
|
||||||
function makeBlock(kind, content) {
|
function makeBlock(kind, content) {
|
||||||
var wrap = document.createElement('div');
|
var wrap = document.createElement('div');
|
||||||
wrap.className = 'note-block';
|
wrap.className = 'note-block';
|
||||||
@@ -53,53 +116,75 @@
|
|||||||
body.className = 'note-block-body';
|
body.className = 'note-block-body';
|
||||||
|
|
||||||
if (kind === 'image') {
|
if (kind === 'image') {
|
||||||
// 图片块:content 是附件 key。填 key 的同时把预览换出来。
|
// 图片块:content 是附件 key(可带 `|宽度`)。填 key 的同时把预览换出来。
|
||||||
|
var parts = String(content || '').split('|');
|
||||||
|
var key = parts[0] || '';
|
||||||
|
var w = parseInt(parts[1] || '0', 10) || 0;
|
||||||
var img = document.createElement('img');
|
var img = document.createElement('img');
|
||||||
img.className = 'note-block-img';
|
img.className = 'note-block-img';
|
||||||
img.alt = '笔记图片';
|
img.alt = '笔记图片';
|
||||||
img.src = content ? (mediaBase + encodeURIComponent(content)) : '';
|
img.src = key ? (mediaBase + encodeURIComponent(key)) : '';
|
||||||
if (!content) { img.classList.add('is-empty'); }
|
if (!key) { img.classList.add('is-empty'); }
|
||||||
|
applyImgWidth(img, w);
|
||||||
|
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'note-img-bar';
|
||||||
var hint = document.createElement('button');
|
var hint = document.createElement('button');
|
||||||
hint.type = 'button';
|
hint.type = 'button';
|
||||||
hint.className = 'note-img-pick';
|
hint.className = 'note-img-pick';
|
||||||
hint.textContent = content ? '换一张' : '选图片';
|
hint.textContent = key ? '换一张' : '选图片';
|
||||||
hint.setAttribute('data-op', 'pick');
|
hint.setAttribute('data-op', 'pick');
|
||||||
|
bar.appendChild(hint);
|
||||||
|
|
||||||
|
/* 🔴 v29 第 8 条:「图文混排时,需要可以调整大小。」
|
||||||
|
四个档位(25 / 50 / 75 / 100%),点一下立即生效。
|
||||||
|
⚠️ 用**百分比**而不是像素:同一张图在手机与桌面上要按容器宽等比缩,
|
||||||
|
写死 px 在窄屏会溢出去(本项目在名册上刚栽过一次溢出)。 */
|
||||||
|
var sizes = document.createElement('span');
|
||||||
|
sizes.className = 'note-img-sizes';
|
||||||
|
[25, 50, 75, 100].forEach(function (pct) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'note-img-size' + ((pct === w || (pct === 100 && w === 0)) ? ' is-on' : '');
|
||||||
|
b.setAttribute('data-op', 'size');
|
||||||
|
b.setAttribute('data-pct', String(pct));
|
||||||
|
b.textContent = pct + '%';
|
||||||
|
sizes.appendChild(b);
|
||||||
|
});
|
||||||
|
bar.appendChild(sizes);
|
||||||
body.appendChild(img);
|
body.appendChild(img);
|
||||||
body.appendChild(hint);
|
body.appendChild(bar);
|
||||||
body.setAttribute('data-key', content || '');
|
body.setAttribute('data-key', key);
|
||||||
|
body.setAttribute('data-w', String(w));
|
||||||
} else {
|
} else {
|
||||||
var ta = document.createElement('textarea');
|
var ta = document.createElement('textarea');
|
||||||
ta.className = 'input note-block-ta';
|
ta.className = 'input note-block-ta';
|
||||||
ta.rows = kind === 'list' ? 4 : 3;
|
ta.rows = 4;
|
||||||
ta.value = content || '';
|
ta.value = content || '';
|
||||||
ta.placeholder = kind === 'list'
|
ta.placeholder = '写点什么…支持 Markdown:**粗体** *斜体* # 标题 - 列表 > 引用 `代码`(行首打 / 选命令)';
|
||||||
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
|
|
||||||
? '一行一项;行首缩进两个空格 = 子项(可多级)'
|
|
||||||
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
|
|
||||||
body.appendChild(ta);
|
body.appendChild(ta);
|
||||||
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。
|
/* 段落下方的实时预览("所见即所得")。
|
||||||
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。
|
⚠️ v29 起**只有段落**这一种文本块了(清单并入 Markdown),
|
||||||
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移
|
所以这里不再需要按 kind 分支。 */
|
||||||
(预览是粗体、保存后不是),而用户看到的就是预览。
|
|
||||||
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大,
|
|
||||||
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */
|
|
||||||
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。
|
|
||||||
段落:原文直接渲染。
|
|
||||||
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
|
|
||||||
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
|
|
||||||
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
|
|
||||||
不必为清单再写一套层级解析(两套必然漂移)。 */
|
|
||||||
if (kind === 'text' || kind === 'list') {
|
|
||||||
var pv = document.createElement('div');
|
var pv = document.createElement('div');
|
||||||
pv.className = 'note-block-pv';
|
pv.className = 'note-block-pv';
|
||||||
pv.setAttribute('data-pv', '1');
|
pv.setAttribute('data-pv', '1');
|
||||||
body.appendChild(pv);
|
body.appendChild(pv);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
wrap.appendChild(body);
|
wrap.appendChild(body);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把宽度百分比应用到 <img>(0 或 100 都不写 style,用默认 100%) */
|
||||||
|
function applyImgWidth(img, pct) {
|
||||||
|
if (!img) { return; }
|
||||||
|
if (pct > 0 && pct < 100) {
|
||||||
|
img.style.width = pct + '%';
|
||||||
|
} else {
|
||||||
|
img.style.width = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
|
/* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
|
||||||
var pvTimer = 0;
|
var pvTimer = 0;
|
||||||
function renderPreview(block) {
|
function renderPreview(block) {
|
||||||
@@ -110,15 +195,7 @@
|
|||||||
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
||||||
var md = ta.value;
|
var md = ta.value;
|
||||||
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
||||||
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级
|
var tok = form.querySelector('input[name="_csrf"]');
|
||||||
var block = pv.closest ? pv.closest('.note-block') : null;
|
|
||||||
if (block && block.getAttribute('data-kind') === 'list') {
|
|
||||||
md = md.split(/\r?\n/).map(function (line) {
|
|
||||||
return line.trim() === '' ? '' : ('- ' + line.replace(/^(\s*)[-*+]\s+/, '$1'));
|
|
||||||
}).join('\n');
|
|
||||||
}
|
|
||||||
var form = document.getElementById('noteForm');
|
|
||||||
var tok = form ? form.querySelector('input[name="_csrf"]') : null;
|
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('md', md);
|
fd.append('md', md);
|
||||||
if (tok) { fd.append('_csrf', tok.value); }
|
if (tok) { fd.append('_csrf', tok.value); }
|
||||||
@@ -150,7 +227,15 @@
|
|||||||
var content;
|
var content;
|
||||||
if (kind === 'image') {
|
if (kind === 'image') {
|
||||||
var b = el.querySelector('.note-block-body');
|
var b = el.querySelector('.note-block-body');
|
||||||
content = (b && b.getAttribute('data-key')) || '';
|
if (!b) { return; }
|
||||||
|
var key = b.getAttribute('data-key') || '';
|
||||||
|
var w = parseInt(b.getAttribute('data-w') || '0', 10) || 0;
|
||||||
|
// 宽度只在 20~99 时写进内容(100 是默认,不必写)
|
||||||
|
/* ⚠️ 宽度只在**有图**时才写进 content(`key|50`)——没有 key 的
|
||||||
|
`|50` 是无效内容(服务端会当空块丢掉,用户看到"刚选的尺寸没了")。
|
||||||
|
但**尺寸本身要留住**(存在 data-w 上):用户可能先点 50% 再选图,
|
||||||
|
选完图那一刻 serialize 会带上宽度(见下面)。 */
|
||||||
|
content = (key !== '' && w > 20 && w < 100) ? (key + '|' + w) : key;
|
||||||
} else {
|
} else {
|
||||||
var ta = el.querySelector('textarea');
|
var ta = el.querySelector('textarea');
|
||||||
content = ta ? ta.value : '';
|
content = ta ? ta.value : '';
|
||||||
@@ -189,7 +274,7 @@
|
|||||||
|
|
||||||
// 事件委托:统一处理块上的所有按钮
|
// 事件委托:统一处理块上的所有按钮
|
||||||
list.addEventListener('click', function (e) {
|
list.addEventListener('click', function (e) {
|
||||||
var btn = e.target.closest('[data-op]');
|
var btn = e.target.closest ? e.target.closest('[data-op]') : null;
|
||||||
if (!btn || !list.contains(btn)) { return; }
|
if (!btn || !list.contains(btn)) { return; }
|
||||||
var op = btn.getAttribute('data-op');
|
var op = btn.getAttribute('data-op');
|
||||||
var el = btn.closest('.note-block');
|
var el = btn.closest('.note-block');
|
||||||
@@ -204,10 +289,38 @@
|
|||||||
fileIn.click();
|
fileIn.click();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (op === 'size') {
|
||||||
|
// 🔴 v29 第 8 条:调图片大小(就地生效 + 存进块内容)
|
||||||
|
var b = el.querySelector('.note-block-body');
|
||||||
|
var pct = parseInt(btn.getAttribute('data-pct') || '100', 10) || 100;
|
||||||
|
b.setAttribute('data-w', String(pct));
|
||||||
|
applyImgWidth(el.querySelector('.note-block-img'), pct);
|
||||||
|
Array.prototype.forEach.call(el.querySelectorAll('.note-img-size'), function (x) {
|
||||||
|
x.classList.toggle('is-on', x === btn);
|
||||||
|
});
|
||||||
|
serialize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 顶部「+段落 / +图片 / +清单」
|
/* 🔴 v29 第 7 条:工具条(点一下插到光标处)。
|
||||||
document.querySelectorAll('.note-add [data-add]').forEach(function (b) {
|
⚠️ 作用对象是**最后聚焦的那个 textarea**(`lastTa`)—— 点按钮时焦点已经
|
||||||
|
离开 textarea 了,光看 `document.activeElement` 是拿不到它的。 */
|
||||||
|
var lastTa = null;
|
||||||
|
list.addEventListener('focusin', function (e) {
|
||||||
|
if (e.target && e.target.tagName === 'TEXTAREA') { lastTa = e.target; }
|
||||||
|
});
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('[data-cmdbar] [data-cmd]'),
|
||||||
|
function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
var ta = lastTa || list.querySelector('textarea');
|
||||||
|
applyCmd(ta, b.getAttribute('data-cmd'));
|
||||||
|
if (ta) { schedulePreview(ta.closest('.note-block')); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 顶部「+段落 / +图片」
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.note-add [data-add]'), function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var kind = b.getAttribute('data-add');
|
var kind = b.getAttribute('data-add');
|
||||||
if (kind === 'image') {
|
if (kind === 'image') {
|
||||||
@@ -221,6 +334,161 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
🔴 v29 第 7 条:**斜线命令**(行首打 `/` 弹出菜单)
|
||||||
|
---------------------------------------------------------------------
|
||||||
|
用户原话:「例如插入一个斜线,然后输入代码,例如 code,
|
||||||
|
就能自动补全 code 的 markdown 代码。」
|
||||||
|
⇒ 在段落 textarea 里:光标所在的**整行**是 `/xxx` 形态时弹出候选;
|
||||||
|
继续打字是**过滤**,回车/点击选中即执行那个命令。
|
||||||
|
|
||||||
|
⚠️⚠️ 判据挂在 `input` 事件上,**不是** `keydown('/')` ——
|
||||||
|
第一版写的是"按下 `/` 时 setTimeout(0) 再回头看光标前一个字符是不是 `/`"。
|
||||||
|
实测**必现失败**:打字快时(或粘贴、或中文输入法上屏、或自动化测试的
|
||||||
|
`type()` 连打)等到那个 timeout 跑起来,光标前一个字符早就是 `o` 了,
|
||||||
|
于是菜单**永远不弹** —— 用户看到的就是"这个功能没做"。
|
||||||
|
正确姿势:每次输入后看**整行**(行首到光标)是不是 `/` + 若干词字符。
|
||||||
|
这样无论打得多快、是不是一次粘贴进来,判断都成立。 */
|
||||||
|
function slashContext(ta) {
|
||||||
|
var v = ta.value;
|
||||||
|
var pos = ta.selectionStart;
|
||||||
|
if (pos !== ta.selectionEnd) { return null; } // 有选区时不弹
|
||||||
|
var before = v.slice(0, pos);
|
||||||
|
var lineStart = before.lastIndexOf('\n') + 1;
|
||||||
|
var line = before.slice(lineStart);
|
||||||
|
var m = /^\/([A-Za-z]*)$/.exec(line);
|
||||||
|
if (!m) { return null; }
|
||||||
|
return {start: lineStart, q: m[1].toLowerCase()};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSlash() {
|
||||||
|
if (slashState && slashState.box && slashState.box.parentNode) {
|
||||||
|
slashState.box.parentNode.removeChild(slashState.box);
|
||||||
|
}
|
||||||
|
slashState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slashItemsFor(q) {
|
||||||
|
var out = [];
|
||||||
|
Object.keys(CMDS).forEach(function (k) {
|
||||||
|
var c = CMDS[k];
|
||||||
|
var hit = k.indexOf(q) === 0 || c.word.indexOf(q) === 0
|
||||||
|
|| (c.keys || []).some(function (x) { return x.indexOf(q) === 0; });
|
||||||
|
if (hit) { out.push([k, c]); }
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSlash(ta, ctx) {
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'slash-menu';
|
||||||
|
// 挂在块上(不是 body):跟着块走,块被删/移动时不会留下孤儿菜单
|
||||||
|
var host = ta.closest('.note-block-body') || ta.parentNode;
|
||||||
|
host.appendChild(box);
|
||||||
|
slashState = { ta: ta, box: box, start: ctx.start, items: [] };
|
||||||
|
renderSlash(ctx.q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前打开的斜线菜单状态:{ta, box, start, items};null = 没开 */
|
||||||
|
var slashState = null;
|
||||||
|
|
||||||
|
/** 输入过程中同步菜单:该弹就弹、该换词就换词、不该弹就收起来 */
|
||||||
|
function syncSlash(ta) {
|
||||||
|
var ctx = slashContext(ta);
|
||||||
|
if (!ctx) { closeSlash(); return; }
|
||||||
|
var items = slashItemsFor(ctx.q);
|
||||||
|
if (!items.length) { closeSlash(); return; }
|
||||||
|
if (!slashState || slashState.ta !== ta) { openSlash(ta, ctx); return; }
|
||||||
|
slashState.start = ctx.start;
|
||||||
|
renderSlash(ctx.q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlash(q) {
|
||||||
|
if (!slashState) { return; }
|
||||||
|
var items = slashItemsFor(q);
|
||||||
|
slashState.items = items;
|
||||||
|
var box = slashState.box;
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!items.length) { box.classList.remove('is-on'); return; }
|
||||||
|
box.classList.add('is-on');
|
||||||
|
items.forEach(function (it, i) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'slash-item' + (i === 0 ? ' is-on' : '');
|
||||||
|
b.setAttribute('data-slash', it[0]);
|
||||||
|
b.innerHTML = '<b>/' + it[0] + '</b><span>' + it[1].tip + '</span>';
|
||||||
|
b.addEventListener('mousedown', function (ev) {
|
||||||
|
ev.preventDefault(); // 别让 textarea 失焦(失焦就取不到选区了)
|
||||||
|
runSlash(it[0]);
|
||||||
|
});
|
||||||
|
box.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSlash(key) {
|
||||||
|
if (!slashState) { return; }
|
||||||
|
var ta = slashState.ta;
|
||||||
|
var box = slashState.box;
|
||||||
|
// 先把 `/xxx` 这段从 textarea 里剪掉(它只是命令,不该留在正文里)
|
||||||
|
var v = ta.value;
|
||||||
|
var upto = ta.selectionStart;
|
||||||
|
var cut = v.slice(0, slashState.start) + v.slice(upto);
|
||||||
|
ta.value = cut;
|
||||||
|
ta.selectionStart = ta.selectionEnd = slashState.start;
|
||||||
|
// 关掉菜单(先关再执行,applyCmd 会重新 focus)
|
||||||
|
if (box && box.parentNode) { box.parentNode.removeChild(box); }
|
||||||
|
slashState = null;
|
||||||
|
applyCmd(ta, key);
|
||||||
|
schedulePreview(ta.closest('.note-block'));
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('keydown', function (e) {
|
||||||
|
var ta = e.target;
|
||||||
|
if (!ta || ta.tagName !== 'TEXTAREA') { return; }
|
||||||
|
|
||||||
|
if (slashState && slashState.ta === ta) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeSlash(); return; }
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
if (slashState.items.length) {
|
||||||
|
e.preventDefault(); // 别让它换行
|
||||||
|
runSlash(slashState.items[0][0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
var bs = Array.prototype.slice.call(slashState.box.querySelectorAll('.slash-item'));
|
||||||
|
if (bs.length > 1) {
|
||||||
|
var cur = bs.findIndex(function (x) { return x.classList.contains('is-on'); });
|
||||||
|
var nxt = ((cur + (e.key === 'ArrowDown' ? 1 : -1)) % bs.length + bs.length) % bs.length;
|
||||||
|
bs.forEach(function (x, i) { x.classList.toggle('is-on', i === nxt); });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输入时:刷新斜线过滤 + 预览
|
||||||
|
list.addEventListener('input', function (e) {
|
||||||
|
serialize();
|
||||||
|
var ta = e.target;
|
||||||
|
if (!ta || ta.tagName !== 'TEXTAREA') { return; }
|
||||||
|
var block = ta.closest('.note-block');
|
||||||
|
|
||||||
|
// 🔴 v29 第 7 条:斜线菜单在**每次输入后**按"整行是不是 /xxx"重判
|
||||||
|
// (见 slashContext 的注释:挂在 keydown('/') 上会漏掉快速输入/粘贴)
|
||||||
|
syncSlash(ta);
|
||||||
|
if (block && block.getAttribute('data-kind') === 'text') {
|
||||||
|
schedulePreview(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.addEventListener('blur', function (e) {
|
||||||
|
// 失焦就关菜单(点别处了)。用 0ms 让"点击候选"先跑完(它是 mousedown)。
|
||||||
|
if (slashState && e.target === slashState.ta) {
|
||||||
|
window.setTimeout(closeSlash, 120);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 图片上传
|
// 图片上传
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -258,6 +526,8 @@
|
|||||||
var img = el.querySelector('.note-block-img');
|
var img = el.querySelector('.note-block-img');
|
||||||
if (img) { img.src = d.url; img.classList.remove('is-empty'); }
|
if (img) { img.src = d.url; img.classList.remove('is-empty'); }
|
||||||
if (pick) { pick.textContent = '换一张'; }
|
if (pick) { pick.textContent = '换一张'; }
|
||||||
|
// ⚠️ 上传完要立刻 serialize:此时 key 才有了值,宽度(data-w)才写进 content。
|
||||||
|
// 否则"先点 50% 再选图"的人,尺寸要到下次改动才生效。
|
||||||
serialize();
|
serialize();
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
||||||
@@ -267,20 +537,7 @@
|
|||||||
fileIn.value = '';
|
fileIn.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
|
/* 实时预览开关("允许所见即所得编辑,也允许 markdown 编辑")——
|
||||||
// + v28:段落块顺手刷新 Markdown 实时预览
|
|
||||||
list.addEventListener('input', function (e) {
|
|
||||||
serialize();
|
|
||||||
var ta = e.target;
|
|
||||||
if (ta && ta.tagName === 'TEXTAREA') {
|
|
||||||
var block = ta.closest ? ta.closest('.note-block') : null;
|
|
||||||
if (block && block.getAttribute('data-kind') === 'text') {
|
|
||||||
schedulePreview(block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* 实时预览开关(用户第 12 条:"允许所见即所得编辑,也允许 markdown 编辑")——
|
|
||||||
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
|
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
|
||||||
var liveBox = document.getElementById('noteLivePreview');
|
var liveBox = document.getElementById('noteLivePreview');
|
||||||
if (liveBox) {
|
if (liveBox) {
|
||||||
@@ -325,7 +582,7 @@
|
|||||||
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
||||||
serialize();
|
serialize();
|
||||||
|
|
||||||
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
/* 编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
||||||
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
|
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
||||||
function (b) { renderPreview(b); });
|
function (b) { renderPreview(b); });
|
||||||
@@ -334,7 +591,8 @@
|
|||||||
// 标签联想:点一下把 #标签 追加进输入框
|
// 标签联想:点一下把 #标签 追加进输入框
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
var tagIn = document.getElementById('noteTags');
|
var tagIn = document.getElementById('noteTags');
|
||||||
document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) {
|
Array.prototype.forEach.call(document.querySelectorAll('.note-tags-suggest [data-tag]'),
|
||||||
|
function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
if (!tagIn) { return; }
|
if (!tagIn) { return; }
|
||||||
var t = '#' + b.getAttribute('data-tag');
|
var t = '#' + b.getAttribute('data-tag');
|
||||||
|
|||||||
@@ -5444,8 +5444,10 @@ $want = array_keys($want);
|
|||||||
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
|
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
|
||||||
// 读一个 user_settings 时间戳,不联网。
|
// 读一个 user_settings 时间戳,不联网。
|
||||||
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
|
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
|
||||||
|
// 🔴 v29 用户第 4 条:这里也改成**响应之后**再同步。
|
||||||
|
// 这条路由是用户日常最常打开的入口,"点进来要等"的感觉主要来自它。
|
||||||
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
||||||
WeRead::maybeSync($familyId, $me, false);
|
WeRead::queueBackgroundSync($familyId, $me);
|
||||||
}
|
}
|
||||||
$f = [
|
$f = [
|
||||||
'status' => (string)($_GET['status'] ?? ''),
|
'status' => (string)($_GET['status'] ?? ''),
|
||||||
@@ -5837,11 +5839,15 @@ $want = array_keys($want);
|
|||||||
}
|
}
|
||||||
if ($act === 'fetch') {
|
if ($act === 'fetch') {
|
||||||
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
|
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
|
||||||
|
// 🔴 v29 用户第 4 条:这里是**用户主动点的按钮**,所以仍然实时取
|
||||||
|
// ("我点一下就要看最新的"是明确预期,不能拿缓存糊弄)。
|
||||||
|
// 但取完**顺手落盘** —— 之后列表页/页头统计就走本地快照了。
|
||||||
$nb = WeRead::notebooks($me);
|
$nb = WeRead::notebooks($me);
|
||||||
if (!$nb['ok']) {
|
if (!$nb['ok']) {
|
||||||
flash('err', (string)$nb['error']);
|
flash('err', (string)$nb['error']);
|
||||||
redirect('books/weread');
|
redirect('books/weread');
|
||||||
}
|
}
|
||||||
|
WeRead::snapNotebooks($me, (array)$nb['books']);
|
||||||
$preview = [];
|
$preview = [];
|
||||||
foreach (array_slice($nb['books'], 0, 40) as $b) {
|
foreach (array_slice($nb['books'], 0, 40) as $b) {
|
||||||
$nt = WeRead::notes($me, (string)$b['bookId']);
|
$nt = WeRead::notes($me, (string)$b['bookId']);
|
||||||
@@ -5945,20 +5951,29 @@ $want = array_keys($want);
|
|||||||
redirect('books');
|
redirect('books');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---- GET:先"顺手同步一次"(用户第 8 条:定期自动同步)----
|
// ---- GET:**不在渲染路径上同步**(v29 用户第 4 条)----
|
||||||
//
|
//
|
||||||
// ⚠️ 位置很要紧:放在**渲染之前**,这样这一页看到的书架就是最新的;
|
// 🔴 用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用
|
||||||
// 而 maybeSync 内部按间隔判定,绝大多数请求只是读一个时间戳(不联网)。
|
// 每次都读取微信读书的数据了。」
|
||||||
|
// v28 只是给同步加了"总预算"(8 秒),但那 8 秒**仍然挡在渲染前面**
|
||||||
|
// (这一段原来就在 View::render 上方)—— 到期那一次还是要等。
|
||||||
|
// ⇒ 现在两件事一起做:
|
||||||
|
// ① 取数改走**本地快照**(`*Cached()`),永不联网;
|
||||||
|
// ② 该同步时**排到响应之后**去跑(queueBackgroundSync),页面立刻出来。
|
||||||
$syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
|
$syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
|
||||||
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
|
||||||
$syncRes = WeRead::maybeSync((int)Auth::familyId(), $me, false);
|
WeRead::queueBackgroundSync((int)Auth::familyId(), $me);
|
||||||
}
|
}
|
||||||
View::render('books/weread', [
|
View::render('books/weread', [
|
||||||
'title' => '微信读书导入',
|
'title' => '微信读书导入',
|
||||||
'preview' => $imported,
|
'preview' => $imported,
|
||||||
'nbCount' => 0,
|
'nbCount' => 0,
|
||||||
'sync' => $syncRes,
|
'sync' => $syncRes,
|
||||||
'stats' => WeRead::readStats($me, 'overall'),
|
// ⚠️ 本地没有快照时(第一次用 / 刚换 Key)允许真取一次:
|
||||||
|
// 否则页面会是一片空白,用户会以为"坏了"。
|
||||||
|
// 这是**首次**,之后每次访问都命中本地 —— 那才是常态。
|
||||||
|
'stats' => WeRead::readStatsCached($me, 'overall')['data'],
|
||||||
|
'snapAt' => WeRead::snapshotAt($me),
|
||||||
], 'books');
|
], 'books');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -6050,7 +6065,21 @@ $want = array_keys($want);
|
|||||||
// 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路:
|
// 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路:
|
||||||
// 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。
|
// 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。
|
||||||
$act = (string)($_POST['act'] ?? '');
|
$act = (string)($_POST['act'] ?? '');
|
||||||
$ok = static function (string $msg): void {
|
/* 🔴 v29 用户第 5 条:设置拆成四项之后,保存完要**回到刚才那一项**,
|
||||||
|
不能一律跳回「我的网址」—— 那会让人以为"设置没保存"(保存后位置变了)。
|
||||||
|
约定:表单里带一个 `back` 隐藏域(= 当前 `?set=` 的值)。
|
||||||
|
⚠️ 白名单校验:`back` 只允许是那四个已知值,别的值一律当空
|
||||||
|
(否则它就是一个开源的重定向参数,能被塞任意 URL)。
|
||||||
|
⚠️ 站点相关动作(site_save/delete…)默认 `back` 为空 → 回「我的网址」,
|
||||||
|
这正是不带 `set` 时的默认视图,符合直觉。 */
|
||||||
|
$backRaw = (string)($_POST['back'] ?? '');
|
||||||
|
$backSet = in_array($backRaw, ['addr', 'general', 'board', 'engine'], true)
|
||||||
|
? $backRaw : '';
|
||||||
|
// 拼"回去哪儿"。⚠️ 用 redirect() 的同一套 url() 拼法(路由 + 附加参数),
|
||||||
|
// 别手写 'nav&set=xxx' 字符串 —— 那样与模板里 url('nav',['set'=>...]) 的
|
||||||
|
// 转义口径就可能不一致(本项目在"斜杠被 urlencode"上栽过一次)。
|
||||||
|
$navBackUrl = $backSet === '' ? url('nav') : url('nav', ['set' => $backSet]);
|
||||||
|
$ok = static function (string $msg) use ($navBackUrl): void {
|
||||||
// AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302
|
// AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302
|
||||||
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
@@ -6058,16 +6087,18 @@ $want = array_keys($want);
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
flash('ok', $msg);
|
flash('ok', $msg);
|
||||||
redirect('nav');
|
header('Location: ' . $navBackUrl);
|
||||||
|
exit;
|
||||||
};
|
};
|
||||||
$bad = static function (string $msg): void {
|
$bad = static function (string $msg) use ($navBackUrl): void {
|
||||||
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
flash('err', $msg);
|
flash('err', $msg);
|
||||||
redirect('nav');
|
header('Location: ' . $navBackUrl);
|
||||||
|
exit;
|
||||||
};
|
};
|
||||||
|
|
||||||
$gid = (int)($_POST['id'] ?? 0);
|
$gid = (int)($_POST['id'] ?? 0);
|
||||||
@@ -6357,29 +6388,62 @@ $want = array_keys($want);
|
|||||||
'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag),
|
'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag),
|
||||||
'showAll' => $showAll,
|
'showAll' => $showAll,
|
||||||
'limit' => Note::LIST_LIMIT,
|
'limit' => Note::LIST_LIMIT,
|
||||||
|
// 卡片上要显示"共享给几个人"(一次查完,别每条一次)
|
||||||
|
'shareN' => Note::shareCounts(array_map(
|
||||||
|
static fn($r): int => (int)$r['id'], $rows)),
|
||||||
], 'notes');
|
], 'notes');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
/* 新建 / 编辑:同一套表单(`id>0` 就是编辑)。
|
/* 笔记页:**默认是阅读态**(v29 用户第 8 条),点「编辑」才进编辑态。
|
||||||
⚠️ 编辑时**只能改自己的**:别人的笔记即使共享给全家也只是"看得到",
|
· `?id=` 看某条(阅读态,图文混排,附件在最下方)
|
||||||
点进编辑页会被挡回列表(不是静默变成新建,那样用户会以为改成功了)。 */
|
· `?id=&edit=1` 编辑某条
|
||||||
|
· 无 `id` 新建(直接就是编辑态 —— 还没有内容可"读")
|
||||||
|
|
||||||
|
🔴 用户原话:「笔记点进来,默认是阅读状态,图文混排,附件在最下方,
|
||||||
|
图文混排时,需要可以调整大小。点击编辑按钮,才进入编辑功能。」
|
||||||
|
⇒ 这一条把 v27/v28 的"点进来就是编辑器"整个反过来:
|
||||||
|
阅读是主路径(笔记多数时间是被看的),编辑是次路径(少数时间被改)。
|
||||||
|
|
||||||
|
⚠️ 权限:能看到就能进(阅读态);能不能**改**由 Note::canWrite 决定
|
||||||
|
(作者本人 或 共享时给了 write)。不能改就只看得到「编辑」按钮之外的
|
||||||
|
内容 —— 按钮不显示,服务端也会挡(两端都判,不只靠前端藏按钮)。 */
|
||||||
case 'notes/form':
|
case 'notes/form':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
Auth::requireWrite();
|
|
||||||
$id = (int)($_GET['id'] ?? 0);
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
$editing = null;
|
|
||||||
|
// 新建必须能写;看/改已有笔记时**不 requireWrite**(访客也能看共享给他们的)
|
||||||
|
if ($id <= 0) {
|
||||||
|
Auth::requireWrite();
|
||||||
|
}
|
||||||
|
$note = null;
|
||||||
|
$perm = '';
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$editing = Note::find($familyId, $id, $me);
|
$note = Note::find($familyId, $id, $me);
|
||||||
if ($editing === null || empty($editing['is_mine'])) {
|
if ($note === null) {
|
||||||
flash('err', '只能编辑自己的笔记。');
|
flash('err', '这条笔记看不到(可能已删除,或者没有共享给你)。');
|
||||||
redirect('notes');
|
redirect('notes');
|
||||||
}
|
}
|
||||||
|
$perm = Note::permOf($note, $me);
|
||||||
}
|
}
|
||||||
|
$canEdit = $id <= 0 ? \App\Auth::canWrite() : Note::canWrite($note ?? [], $me);
|
||||||
|
// `?edit=1` 且真的有写权限 → 编辑态;否则一律阅读态
|
||||||
|
$wantEdit = !empty($_GET['edit']);
|
||||||
|
if ($id > 0 && $wantEdit && !$canEdit) {
|
||||||
|
flash('err', '这条笔记你只有只读权限。');
|
||||||
|
redirect('notes/form', ['id' => $id]);
|
||||||
|
}
|
||||||
|
$editMode = $id <= 0 || $wantEdit;
|
||||||
View::render('notes/form', [
|
View::render('notes/form', [
|
||||||
'title' => $editing ? '改笔记' : '写笔记',
|
'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : '看笔记',
|
||||||
'editing' => $editing,
|
'editing' => $note,
|
||||||
|
'editMode' => $editMode,
|
||||||
|
'perm' => $perm,
|
||||||
|
'canEdit' => $canEdit,
|
||||||
|
'shares' => $id > 0 ? Note::shares($id) : [],
|
||||||
'tagSuggest' => Note::tagSuggest($familyId, $me),
|
'tagSuggest' => Note::tagSuggest($familyId, $me),
|
||||||
|
// 共享要"选人"(v29 第 9 条,与资源页同一个选人组件)
|
||||||
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
], 'notes');
|
], 'notes');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -6410,16 +6474,21 @@ $want = array_keys($want);
|
|||||||
'title' => (string)($_POST['title'] ?? ''),
|
'title' => (string)($_POST['title'] ?? ''),
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
'tags' => (string)($_POST['tags'] ?? ''),
|
'tags' => (string)($_POST['tags'] ?? ''),
|
||||||
'is_shared' => !empty($_POST['is_shared']),
|
|
||||||
];
|
];
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$done = Note::update($familyId, $id, $me, $payload);
|
$done = Note::update($familyId, $id, $me, $payload);
|
||||||
flash($done ? 'ok' : 'err', $done ? '改好了。' : '只能改自己的笔记。');
|
flash($done ? 'ok' : 'err', $done ? '改好了。' : '这条笔记你没有写权限。');
|
||||||
redirect('notes');
|
// 🔴 v29 第 8 条:保存后回到**阅读态**,让用户立刻看到改完的样子
|
||||||
|
// (而不是停在编辑器里,让人不确定到底存上没存上)。
|
||||||
|
redirect('notes/form', ['id' => $id]);
|
||||||
}
|
}
|
||||||
$nid = Note::create($familyId, $me, $payload);
|
$nid = Note::create($familyId, $me, $payload);
|
||||||
flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '什么都没写,没记。');
|
if ($nid > 0) {
|
||||||
|
flash('ok', '记下了。');
|
||||||
|
redirect('notes/form', ['id' => $nid]);
|
||||||
|
}
|
||||||
|
flash('err', '什么都没写,没记。');
|
||||||
redirect('notes');
|
redirect('notes');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -6430,8 +6499,10 @@ $want = array_keys($want);
|
|||||||
csrf_check();
|
csrf_check();
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$n = Note::find($familyId, $id, $me);
|
$n = Note::find($familyId, $id, $me);
|
||||||
if (!$n || empty($n['is_mine'])) {
|
// 🔴 v29:删除**只限作者本人** —— "可读写"给的是编辑权,不是销毁权
|
||||||
flash('err', '只能删自己的笔记。');
|
// (与资源的语义一致:写入受限、删除始终归本人/管理员)。
|
||||||
|
if (!$n || (int)$n['person_id'] !== $me) {
|
||||||
|
flash('err', '只有作者本人能删这条笔记。');
|
||||||
redirect('notes');
|
redirect('notes');
|
||||||
}
|
}
|
||||||
Note::destroy($familyId, $id, $me);
|
Note::destroy($familyId, $id, $me);
|
||||||
@@ -6444,6 +6515,49 @@ $want = array_keys($want);
|
|||||||
共享出去的笔记别人只能看,不该能往里塞文件。
|
共享出去的笔记别人只能看,不该能往里塞文件。
|
||||||
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
|
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
|
||||||
不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */
|
不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */
|
||||||
|
/* ---- 共享(v29 用户第 9 条):选人 + 权限,与资源同一套 ----
|
||||||
|
与资源的 resources/share 路由同一个形状:先设一条(人 + 只读/读写),
|
||||||
|
取消共享走另一条(unshare)。表单全页只有一份,前端填隐藏域提交。 */
|
||||||
|
case 'notes/share':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$pid = (int)($_POST['person_id'] ?? 0);
|
||||||
|
$n = Note::find($familyId, $id, $me);
|
||||||
|
// ⚠️ 只有**作者**能改共享(拿到 write 权限的人不该能把笔记再转给别人 ——
|
||||||
|
// 那等于把作者的分享范围悄悄扩大)。
|
||||||
|
if (!$n || (int)$n['person_id'] !== $me) {
|
||||||
|
flash('err', '只有作者本人能改共享设置。');
|
||||||
|
redirect('notes');
|
||||||
|
}
|
||||||
|
if ($pid <= 0) {
|
||||||
|
flash('err', '得先选一个要共享给谁。');
|
||||||
|
redirect('notes/form', ['id' => $id]);
|
||||||
|
}
|
||||||
|
Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read'));
|
||||||
|
flash('ok', '共享已更新。');
|
||||||
|
redirect('notes/form', ['id' => $id]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'notes/unshare':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$pid = (int)($_POST['person_id'] ?? 0);
|
||||||
|
$n = Note::find($familyId, $id, $me);
|
||||||
|
if (!$n || (int)$n['person_id'] !== $me) {
|
||||||
|
flash('err', '只有作者本人能改共享设置。');
|
||||||
|
redirect('notes');
|
||||||
|
}
|
||||||
|
Note::dropShare($id, $pid);
|
||||||
|
flash('ok', '已取消共享。');
|
||||||
|
redirect('notes/form', ['id' => $id]);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'notes/attach_add':
|
case 'notes/attach_add':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
|
|||||||
+9
-10
@@ -470,14 +470,13 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
|||||||
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
|
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
|
||||||
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
|
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
|
||||||
|
|
||||||
⚠️ 触发条件只看**网格在不在**(`$timed` 非空才有网格),**不要**再加 `$showNow`。
|
⚠️ 触发条件:**今天有日程就定位**(只要 `$todayEvents` 非空,页面上就有
|
||||||
第一版写了 `if (!empty($timed) && $showNow)`,后果是:
|
`.ag-grid` 与红线)。曾经写成 `if (!$todayEvents || empty($timed))`,
|
||||||
· 红线落在区间外(现在 23:40、日程只到 18 点 → 页面不画红线)时**整段脚本不渲染**
|
后果是**今天只有全天日程 / 全天日程都没有分时段的那种日子,脚本整段不输出**
|
||||||
→ "每次进来都要定位"这条需求在最常见的场景(晚上开首页)直接失效;
|
—— 而网格(0–24h)和红线照样渲染,于是"红线在,页面却停在最上面"
|
||||||
· 更糟的是**判据依赖了被优化掉的东西**:探针那次库里没有分时段日程,
|
(v29 用户第 1 条仍然报「没有定位到红线」,实测 `scrollTop=0`、脚本不在页面上,
|
||||||
脚本根本没输出,页面看着"没报错",实测 `scrollTop=0`(本轮实测抓到的)。
|
就是这个条件卡掉的;有分时段日程时定位其实完全正确、偏差 0px)。
|
||||||
⇒ 现在:**有网格就定位**。红线在就对齐红线;红线不在(在区间外)就用
|
⇒ 只判"有没有网格可滚",别判"是不是分时段日程"。
|
||||||
`$nowPos` 同一个比例算出"此刻"在网格里的位置来对齐(数值同源,不会两套口径)。
|
|
||||||
|
|
||||||
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
|
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
|
||||||
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
|
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
|
||||||
@@ -486,8 +485,8 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
|||||||
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
|
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
|
||||||
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
|
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
|
||||||
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
|
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
|
||||||
<?php if (!$todayEvents || empty($timed)): ?>
|
<?php if (!$todayEvents): ?>
|
||||||
<?php /* 没有分时段日程 → 没有网格可滚,什么都不做 */ ?>
|
<?php /* 今天一条日程都没有 → 页面是「今天没有安排」的空态,没有网格可滚 */ ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
+106
-15
@@ -55,25 +55,89 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
|
|||||||
用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。
|
用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。
|
||||||
⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**,
|
⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**,
|
||||||
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
|
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
|
||||||
$inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== null;
|
/* 🔴🔴 v29 用户第 5 条:「导航的设置,做成设置按钮,点击后,拉下来设置选项,
|
||||||
|
分别是地址设置、导航设置、板块设置、搜索引擎设置,把现在的设置页面,拆开,
|
||||||
|
放在不同的设置选项下。」
|
||||||
|
⇒ 从"一个大的设置页"改成**下拉菜单 + 四个独立视图**:
|
||||||
|
· `?set=addr` 地址设置(展示页地址 + 短语)
|
||||||
|
· `?set=general` 导航设置(标题/搜索引擎/布局等,原 SimpleNav 的 settings)
|
||||||
|
· `?set=board` 板块设置
|
||||||
|
· `?set=engine` 搜索引擎设置
|
||||||
|
· 不带 `set` 默认:新增网址 + 网址列表
|
||||||
|
⚠️ 为什么拆开(而不只是把四块堆在一个?"设置页"里):
|
||||||
|
用户点的原话就是"拆开"。而且四块各自是**独立表单**,堆在一页里
|
||||||
|
每次保存后 302 回来都要重新找自己在哪一块。分开之后
|
||||||
|
"你在哪一块"由 URL 决定,保存后回到原地,位置感清楚。
|
||||||
|
⚠️ 用**链接**(服务端渲染)而不是 JS 下拉切换内容:
|
||||||
|
与 v28 的 `?set=1` 同一理由 —— 设置里有表单,保存会 302 回来,
|
||||||
|
纯 JS 的展开状态在刷新后就丢了。`<details>` 只负责"菜单展开"这一个动效,
|
||||||
|
点条目是**真实导航**(URL 变、视图变、刷新/回退都不丢)。
|
||||||
|
⚠️ 编辑态(正在编辑某板块/某引擎)要落到对应的那个视图里,
|
||||||
|
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
|
||||||
|
$setKey = (string)($_GET['set'] ?? '');
|
||||||
|
if ($editGroup !== null) { $setKey = 'board'; }
|
||||||
|
if ($editEngine !== null) { $setKey = 'engine'; }
|
||||||
|
$inSettings = in_array($setKey, ['addr', 'general', 'board', 'engine'], true);
|
||||||
|
|
||||||
|
/* 设置项的登记表(菜单与"当前在哪一项"的标题都读它,只有一份) */
|
||||||
|
$setItems = [
|
||||||
|
'addr' => ['地址设置', 'M2.5 4h11M2.5 8h7M2.5 12h9'],
|
||||||
|
'general' => ['导航设置', 'M8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z M8 1.5v1.6M8 12.9v1.6M1.5 8h1.6M12.9 8h1.6M3.4 3.4l1.1 1.1M11.5 11.5l1.1 1.1M12.6 3.4l-1.1 1.1M4.5 11.5l-1.1 1.1'],
|
||||||
|
'board' => ['板块设置', 'M2 3.5h4v4H2zM10 3.5h4v4h-4zM2 9.5h4v4H2zM10 9.5h4v4h-4z'],
|
||||||
|
'engine' => ['搜索引擎设置', 'M7 2.5a4.5 4.5 0 100 9 4.5 4.5 0 000-9z M10.4 10.4L14 14'],
|
||||||
|
];
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>导航</h1>
|
<h1><?= $inSettings ? e($setItems[$setKey][0]) : '导航' ?></h1>
|
||||||
<span class="sub">你个人用的网址导航 · 每人一份</span>
|
<span class="sub">
|
||||||
<div class="actions">
|
|
||||||
<?php /* v28 用户第 11 条:右上角「设置」按钮,与其他模块的手指位置一致。
|
|
||||||
⚠️ 它是个**链接**(服务端切换视图),不是 JS 开关 —— 理由见上面的注释。 */ ?>
|
|
||||||
<?php if ($inSettings): ?>
|
<?php if ($inSettings): ?>
|
||||||
<a class="btn btn-sm" href="<?= e(url('nav')) ?>">完成</a>
|
<a href="<?= e(url('nav')) ?>">← 回到网址列表</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['set' => 1])) ?>">设置</a>
|
你个人用的网址导航 · 每人一份
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<div class="actions">
|
||||||
|
<?php /* 🔴 v29 用户第 5 条:右上角「设置」点开是**下拉菜单**,四项分设。
|
||||||
|
⚠️ 用项目已有的 `.movie-set.menu`(任务/观影模块同款):
|
||||||
|
外观、点击外部收起、键盘可达全都是现成的,
|
||||||
|
这里再手写一套 `<details>` 样式,迟早跟那边不一致。 */ ?>
|
||||||
|
<details class="movie-set menu">
|
||||||
|
<summary class="btn btn-ghost btn-sm" title="导航设置" aria-label="导航设置">
|
||||||
|
设置
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" style="margin-left:2px;vertical-align:-1px">
|
||||||
|
<path d="M4 6.5l4 4 4-4"/>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="menu-list">
|
||||||
|
<?php foreach ($setItems as $k => $it): ?>
|
||||||
|
<a href="<?= e(url('nav', ['set' => $k])) ?>"<?= $setKey === $k ? ' class="on"' : '' ?>>
|
||||||
|
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="<?= e($it[1]) ?>"/>
|
||||||
|
</svg>
|
||||||
|
<span><?= e($it[0]) ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<a href="<?= e(url('nav')) ?>">
|
||||||
|
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 8l3.5 3.5L13 5"/>
|
||||||
|
</svg>
|
||||||
|
<span>我的网址</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
|
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
|
||||||
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
|
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
|
||||||
<?php if ($inSettings): ?><?php /* ==== 设置视图开始 ==== */ ?>
|
<?php /* ==== 地址设置(?set=addr)==== */ ?>
|
||||||
|
<?php if ($setKey === 'addr'): ?>
|
||||||
|
|
||||||
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
|
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
|
||||||
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
|
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
|
||||||
@@ -117,17 +181,23 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form"
|
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form"
|
||||||
onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')">
|
onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="back" value="addr">
|
||||||
<input type="hidden" name="act" value="phrase_random">
|
<input type="hidden" name="act" value="phrase_random">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
|
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?><?php /* ==== /地址设置 ==== */ ?>
|
||||||
|
|
||||||
|
<?php /* ==== 导航设置(?set=general)==== */ ?>
|
||||||
|
<?php if ($setKey === 'general'): ?>
|
||||||
<?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?>
|
<?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2">导航设置</h2>
|
<h2 class="cinema-h2">导航设置</h2>
|
||||||
<form method="post" action="<?= e(url('nav/save')) ?>">
|
<form method="post" action="<?= e(url('nav/save')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="back" value="general">
|
||||||
<input type="hidden" name="act" value="settings_save">
|
<input type="hidden" name="act" value="settings_save">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -196,10 +266,15 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php endif; ?><?php /* ==== 设置视图(前半:展示地址 + 导航设置)结束 ==== */ ?>
|
<?php endif; ?><?php /* ==== /导航设置 ==== */ ?>
|
||||||
|
|
||||||
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?>
|
<?php /* ==================== ③ 站点:新增/编辑 ==================== */
|
||||||
<?php if ($canWrite): ?>
|
/* 🔴 v29 用户第 5 条:这两块(新增站点 + 站点列表)是**默认视图**的内容,
|
||||||
|
设置视图(addr/general/board/engine)里不该出现 —— 用户原话是
|
||||||
|
「进来之后,默认就是添加网址,和所有网址的列表」,反过来说
|
||||||
|
进了设置就不该再看到"添加网址"。 */
|
||||||
|
?>
|
||||||
|
<?php if (!$inSettings && $canWrite): ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
|
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
|
||||||
<?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了,
|
<?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了,
|
||||||
@@ -276,6 +351,7 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
|
<?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
|
||||||
|
<?php if (!$inSettings): ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2">
|
<h2 class="cinema-h2">
|
||||||
站点列表
|
站点列表
|
||||||
@@ -327,13 +403,24 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?><?php /* ④ 站点列表(仅默认视图) */ ?>
|
||||||
|
<?php /* ⚠️ 这里必须补上 277 行那个 `if (!$inSettings && $canWrite)` 的收尾 ——
|
||||||
|
它原本一路开到了文件末尾(607 行),把**板块设置 / 搜索引擎设置**两个视图
|
||||||
|
也一起包进了"非设置态"里 → 打开 `?set=board` 时整段被跳过,
|
||||||
|
页面标题显示"板块设置"、内容却是空的(本轮实测抓到的真 bug:
|
||||||
|
`<!--DBG-->` 探针证明执行根本没走到 409 行)。
|
||||||
|
⇒ 「非设置态才显示」只该管**站点编辑 + 站点列表**这两块(见 v29 第 5 条),
|
||||||
|
设置类视图(addr/general/board/engine)各自独立、不受它管。 */ ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($inSettings): ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)重新开始 ==== */ ?>
|
<?php /* ==== 板块设置(?set=board)==== */ ?>
|
||||||
|
<?php if ($setKey === 'board'): ?>
|
||||||
<?php /* ==================== ⑤ 板块 ==================== */ ?>
|
<?php /* ==================== ⑤ 板块 ==================== */ ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
|
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
|
||||||
<form method="post" action="<?= e(url('nav/save')) ?>">
|
<form method="post" action="<?= e(url('nav/save')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="back" value="board">
|
||||||
<input type="hidden" name="act" value="group_save">
|
<input type="hidden" name="act" value="group_save">
|
||||||
<?php if ($editGroup): ?>
|
<?php if ($editGroup): ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>">
|
||||||
@@ -390,11 +477,16 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?><?php /* ==== /板块设置 ==== */ ?>
|
||||||
|
|
||||||
|
<?php /* ==== 搜索引擎设置(?set=engine)==== */ ?>
|
||||||
|
<?php if ($setKey === 'engine'): ?>
|
||||||
<?php /* ==================== ⑥ 搜索引擎 ==================== */ ?>
|
<?php /* ==================== ⑥ 搜索引擎 ==================== */ ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2>
|
<h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2>
|
||||||
<form method="post" action="<?= e(url('nav/save')) ?>">
|
<form method="post" action="<?= e(url('nav/save')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="back" value="engine">
|
||||||
<input type="hidden" name="act" value="engine_save">
|
<input type="hidden" name="act" value="engine_save">
|
||||||
<?php if ($editEngine): ?>
|
<?php if ($editEngine): ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>">
|
||||||
@@ -519,8 +611,7 @@ $inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== nul
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?><?php /* ==== /搜索引擎设置 ==== */ ?>
|
||||||
<?php endif; ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)结束 ==== */ ?>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* 导航管理页的交互(v24 第 24 批)。
|
/* 导航管理页的交互(v24 第 24 批)。
|
||||||
|
|||||||
+226
-63
@@ -1,40 +1,62 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 个人笔记 · 写 / 改(v27 用户 2026-10-07)。新建与编辑**同一个模板**。
|
* 个人笔记 · 一条笔记的两种面貌(v29 用户 2026-10-08 第 6/7/8/9 条)。
|
||||||
*
|
*
|
||||||
* 「分块」的落点就在这一页:正文区由若干**块**组成,每块是
|
* 🔴 用户原话(第 8 条):
|
||||||
* 段落 / 图片 / 清单 三者之一,可增、可删、可上下移。
|
* 「笔记点进来,默认是阅读状态,图文混排,附件在最下方,图文混排时,
|
||||||
|
* 需要可以调整大小。点击编辑按钮,才进入编辑功能。
|
||||||
|
* 清单功能不要了吧,markdown 里面包含这个功能,把段落做的完善一点就可以了。」
|
||||||
|
* ⇒ 这个模板现在有**两个态**:
|
||||||
|
* · **阅读态**(`$editMode = false`):图文按顺序排下来、附件在最底下、
|
||||||
|
* 右上角一个「编辑」按钮。多数时候用户是来看的。
|
||||||
|
* · **编辑态**(`$editMode = true`):分块编辑器 + 工具条 + Markdown 补全。
|
||||||
*
|
*
|
||||||
* 三条刻意的轻量取舍(写清楚,免得后人"顺手升级"成重型编辑器):
|
* 🔴 用户原话(第 7 条):
|
||||||
* ① **不做富文本工具栏**(粗体/斜体/颜色…)。本项目全站的文本都是"纯文本 +
|
* 「添加段落部分,还是不太好,这样就意味着用户还是要记住所有的 markdown 命令,
|
||||||
* 换行",只有这里破例就会出现两套文本口径(导出、搜索、复制都会不一致)。
|
* 要么有工具条,要么有代码提示,例如插入一个斜线,然后输入代码,例如 code,
|
||||||
* 需要强调就写符号,需要排版就用清单块。
|
* 就能自动补全 code 的 markdown 代码。」
|
||||||
* ② **不做 Markdown 预览**。可见即所得:段落块就是段落,清单块就是清单 ——
|
* ⇒ **两样都给**(用户原话就是"要么…要么…",那两样都给最省事):
|
||||||
* 用户不必再学"我打的东西会变成什么"。这也是"轻量"最实在的一层。
|
* · 工具条:粗体 / 斜体 / 标题 / 列表 / 引用 / 代码 / 链接 —— 点一下插到光标处;
|
||||||
* ③ **不做拖拽排序**,只有 ↑ ↓ 两个按钮。鼠标拖拽要引入一整套 pointer 事件
|
* · 斜线提示:行首打 `/` 弹出命令菜单,打 `code` 回车就补上代码块。
|
||||||
* 与触摸兼容(本仓名册拖拽就为它写了上千行),而笔记的块通常只有几块,
|
|
||||||
* 两个按钮足够,代码少一个数量级。
|
|
||||||
*
|
*
|
||||||
* ⚠️ 提交形状:所有块打包成一个 JSON 放进隐藏域 `blocks`(见页尾脚本)。
|
* ⚠️ 为什么"阅读态"要单独一份渲染(而不是复用编辑器的预览):
|
||||||
* 为什么不是 `kind[]` / `content[]` 并行数组:服务端要按下标对齐,
|
* 编辑器里的预览是"每块一个方框、带虚线边框",那是**编辑**的视觉语言;
|
||||||
* 一旦前端少发一个(比如空块被过滤)整个错位,而且很难在页面上看出来。
|
* 阅读态要的是干净的排版(没有框、没有按钮)。两者混用会让阅读页
|
||||||
* JSON 一把梭,服务端 `json_decode` 之后逐块清洗,简单且不会错位。
|
* 看着像"还没保存的草稿"。
|
||||||
* ⚠️ **无 JS 兜底**:`<noscript>` 里给一个普通 textarea(`body_plain`),
|
* ⚠️ 图片"可以调整大小"(第 8 条):用 width 百分比存进图片块的内容里
|
||||||
* 服务端在 `blocks` 为空时会退回读它(见 index.php 的 notes/save)。
|
* (`key|50`),渲染时按它出宽度。见下面 `$imgW` 的解析与注释。
|
||||||
*
|
*
|
||||||
* @param array|null $editing 编辑时的笔记(带 blocks/tags/is_shared);新建为 null
|
* @param array|null $editing 笔记(阅读/编辑都以它为准);新建时 null
|
||||||
|
* @param bool $editMode true = 编辑态
|
||||||
|
* @param string $perm 'owner' / 'write' / 'read' / ''
|
||||||
|
* @param bool $canEdit 能不能改(作者或 write)
|
||||||
|
* @param array $shares 已共享给谁(作者视角才有内容)
|
||||||
* @param array $tagSuggest 我用过的标签(点一下就加)
|
* @param array $tagSuggest 我用过的标签(点一下就加)
|
||||||
*/
|
*/
|
||||||
$editing = $editing ?? null;
|
$editing = $editing ?? null;
|
||||||
$isEditing = $editing !== null;
|
$isEditing = $editing !== null;
|
||||||
|
$editMode = (bool)($editMode ?? ($isEditing ? false : true));
|
||||||
$canWrite = \App\Auth::canWrite();
|
$canWrite = \App\Auth::canWrite();
|
||||||
|
$perm = (string)($perm ?? '');
|
||||||
|
$canEdit = (bool)($canEdit ?? false);
|
||||||
|
$shares = (array)($shares ?? []);
|
||||||
|
$isMine = $isEditing && (int)$editing['person_id'] === (int)\App\Auth::personId();
|
||||||
|
|
||||||
// 把编辑中的数据整理成前端要的形状(块数组 + 标签词)
|
// 把编辑中的数据整理成前端要的形状(块数组 + 标签词)
|
||||||
$blocks = [];
|
$blocks = [];
|
||||||
$tagsStr = '';
|
$tagsStr = '';
|
||||||
if ($isEditing) {
|
if ($isEditing) {
|
||||||
foreach ((array)$editing['blocks'] as $b) {
|
foreach ((array)$editing['blocks'] as $b) {
|
||||||
$blocks[] = ['kind' => (string)$b['kind'], 'content' => (string)$b['content']];
|
// ⚠️ 历史 list 块在编辑态里**转成 Markdown 文本**再放进编辑器:
|
||||||
|
// 否则编辑器认不出这种块(v29 已把清单并入 Markdown),
|
||||||
|
// 会渲染成一个空的方块 —— 用户一保存内容就丢了。
|
||||||
|
$k = (string)$b['kind'];
|
||||||
|
$c = (string)$b['content'];
|
||||||
|
if ($k === 'list') {
|
||||||
|
$c = \App\Note::blockHtmlOut($c); // 见 Note::blockHtmlOut(返回 Markdown 文本)
|
||||||
|
$k = 'text';
|
||||||
|
}
|
||||||
|
$blocks[] = ['kind' => $k, 'content' => $c];
|
||||||
}
|
}
|
||||||
$tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
|
$tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
|
||||||
}
|
}
|
||||||
@@ -42,18 +64,170 @@ if (!$blocks) {
|
|||||||
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
|
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
|
||||||
$blocks = [['kind' => 'text', 'content' => '']];
|
$blocks = [['kind' => 'text', 'content' => '']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图片块内容 → [key, 宽度百分比]。内容形如 `abc123` 或 `abc123|50`。 */
|
||||||
|
$imgParts = static function (string $c): array {
|
||||||
|
$p = explode('|', $c);
|
||||||
|
$w = isset($p[1]) ? (int)$p[1] : 0;
|
||||||
|
if ($w < 20 || $w > 100) { $w = 0; }
|
||||||
|
return [trim((string)$p[0]), $w];
|
||||||
|
};
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><?= $isEditing ? '改笔记' : '写笔记' ?></h1>
|
<h1><?= $editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记' ?></h1>
|
||||||
|
<span class="sub">
|
||||||
|
<?php if ($isEditing && !$editMode): ?>
|
||||||
|
<?= e(substr((string)$editing['updated_at'], 0, 16)) ?>
|
||||||
|
<?php if (!$isMine): ?>
|
||||||
|
· 作者 <?= e((string)($editing['author_name'] ?? '')) ?>
|
||||||
|
· 你的权限:<?= e(\App\Note::permWord($perm)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">返回列表</a>
|
<?php /* 🔴 v29 第 8 条:阅读态的右上角就是那个「编辑」按钮 —— 用户明确说
|
||||||
|
"点击编辑按钮,才进入编辑功能"。 */ ?>
|
||||||
|
<?php if ($isEditing && !$editMode && $canEdit && $canWrite): ?>
|
||||||
|
<a class="btn btn-sm" href="<?= e(url('notes/form', ['id' => (int)$editing['id'], 'edit' => 1])) ?>">编辑</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">返回列表</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!$canWrite): ?>
|
<?php if (!$isEditing && !$canWrite): ?>
|
||||||
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
|
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
|
||||||
|
<?php elseif (!$editMode): ?>
|
||||||
|
<?php /* ==================== ✅ 阅读态(默认)====================
|
||||||
|
用户第 8 条要的"图文混排 + 附件在最下方",就在这一段。
|
||||||
|
⚠️ 图片块用 `<figure>` 包:宽度可调(第 8 条),且要与正文有间距。
|
||||||
|
⚠️ 附件(`$editing['files']`)**排在正文之后、页面最下方** ——
|
||||||
|
用户原话「附件在最下方」。 */ ?>
|
||||||
|
<article class="note-read card card-pad">
|
||||||
|
<?php $title = trim((string)($editing['title'] ?? '')); ?>
|
||||||
|
<?php if ($title !== ''): ?>
|
||||||
|
<h1 class="note-read-title"><?= e($title) ?></h1>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($editing['tags'])): ?>
|
||||||
|
<div class="note-tags note-read-tags">
|
||||||
|
<?php foreach ((array)$editing['tags'] as $t): ?>
|
||||||
|
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">#<?= e((string)$t) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="note-read-body">
|
||||||
|
<?php foreach ((array)$editing['blocks'] as $b): ?>
|
||||||
|
<?php $k = (string)$b['kind']; $c = (string)$b['content']; ?>
|
||||||
|
<?php if ($k === 'image'): ?>
|
||||||
|
<?php [$key, $w] = $imgParts($c); ?>
|
||||||
|
<?php if ($key !== ''): ?>
|
||||||
|
<figure class="note-read-fig">
|
||||||
|
<a href="<?= e(media_url($key)) ?>" data-prev>
|
||||||
|
<img src="<?= e(media_url($key)) ?>" alt=""
|
||||||
|
<?= $w > 0 ? 'style="width:' . (int)$w . '%"' : '' ?>
|
||||||
|
loading="lazy" decoding="async">
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="note-read-block md-body"><?= (string)($b['html'] ?? '') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (!$editing['blocks']): ?>
|
||||||
|
<p class="empty-hint">(这条笔记没有正文)</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php /* ---- 附件:永远在最后 ---- */ ?>
|
||||||
|
<?php if (!empty($editing['files'])): ?>
|
||||||
|
<section class="note-read-files">
|
||||||
|
<h2 class="note-read-h2">附件</h2>
|
||||||
|
<div class="note-files">
|
||||||
|
<?php foreach ((array)$editing['files'] as $f): ?>
|
||||||
|
<?php $fk = \App\Attachment::kind($f); ?>
|
||||||
|
<div class="note-file">
|
||||||
|
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
|
||||||
|
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
|
||||||
|
<?= e((string)($f['name'] ?? '附件')) ?>
|
||||||
|
</a>
|
||||||
|
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<?php /* ==================== 共享(v29 第 9 条:选人 + 权限,同资源)====================
|
||||||
|
🔴 只在**作者**视角显示:共享范围是作者的决定,拿到只读的人不该看到名单。
|
||||||
|
交互与资源页完全一致:选人 → 选权限 → 共享;已共享的逐个可「取消共享」。 */ ?>
|
||||||
|
<?php if ($isMine && $canWrite): ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">共享
|
||||||
|
<span class="hint">默认只有自己看得到;共享出去给别人看或改</span></h2>
|
||||||
|
|
||||||
|
<form method="post" action="<?= e(url('notes/share')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>共享给谁</label>
|
||||||
|
<?php
|
||||||
|
$ppName = 'person_id';
|
||||||
|
$ppPeople = $people ?? [];
|
||||||
|
$ppSelected = [];
|
||||||
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '选择要共享给谁';
|
||||||
|
$ppTitle = '共享给谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
|
<span class="tip">你自己本来就有全部权限,不用(也不能)共享给自己。</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>权限</label>
|
||||||
|
<select class="select" name="perm">
|
||||||
|
<option value="read">只读</option>
|
||||||
|
<option value="write">可读写</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="margin-top:0">
|
||||||
|
<button class="btn btn-ok" type="submit">共享</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="note-share-now">
|
||||||
|
<h3 class="note-read-h2">已共享给</h3>
|
||||||
|
<?php if ($shares): ?>
|
||||||
|
<div class="note-files">
|
||||||
|
<?php foreach ($shares as $s): ?>
|
||||||
|
<div class="note-file">
|
||||||
|
<span class="note-file-ico"><?= e(\App\Note::permWord((string)$s['perm'])) ?></span>
|
||||||
|
<span class="note-file-nm"><?= e((string)$s['name']) ?></span>
|
||||||
|
<form method="post" action="<?= e(url('notes/unshare')) ?>" class="inline-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
||||||
|
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
|
||||||
|
<button type="submit" class="note-file-del" title="取消共享">取消共享</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="empty-hint">还没有共享给谁。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
|
<?php /* ==================== ✏️ 编辑态 ==================== */ ?>
|
||||||
|
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
|
||||||
|
<?php /* ⚠️ media 地址**不在表单上注入**:`media_url('')` 返回的是默认头像
|
||||||
|
(见 helpers.php 里 `$key === ''` 那一支),拿它当基址会拼出
|
||||||
|
`avatar-default.svgabc123` 这种地址。基址统一由 layout 的
|
||||||
|
`<body data-media-base="media.php?k=">` 提供,脚本读它就行。 */ ?>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php if ($isEditing): ?>
|
<?php if ($isEditing): ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
||||||
@@ -77,29 +251,23 @@ if (!$blocks) {
|
|||||||
<div class="note-add">
|
<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="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="image">+ 图片</button>
|
||||||
<button class="btn btn-ghost btn-sm" type="button" data-add="list">+ 清单</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* 🔴 v28 用户第 12 条:「段落是需要支持富文本的,可以使用 markdown,
|
<?php /* 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ 斜线命令提示。
|
||||||
但是需要使用富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。
|
工具条按钮与斜线菜单是**同一份命令表**(见 note_editor.js 的 CMDS),
|
||||||
就是说,允许所见即所得编辑,也允许 markdown 编辑。」
|
避免"按钮能插、斜线插不了"这种两套实现。 */ ?>
|
||||||
|
<div class="note-cmdbar" data-cmdbar>
|
||||||
⇒ 两件事:
|
<button type="button" class="cmd" data-cmd="b" title="粗体 **文字**"><b>B</b></button>
|
||||||
① **每个段落块下方实时显示渲染结果**("所见即所得")——
|
<button type="button" class="cmd" data-cmd="i" title="斜体 *文字*"><i>I</i></button>
|
||||||
写 Markdown 的同时就能看到它长什么样,不必切来切去;
|
<span class="cmd-sep"></span>
|
||||||
② 一个**全局"源码 / 预览"开关**:想专心改 Markdown 源码时,
|
<button type="button" class="cmd" data-cmd="h2" title="小标题 ## 文字">H</button>
|
||||||
把实时预览关掉(长文档下预览会占很多地方,也会让人分心)。
|
<button type="button" class="cmd" data-cmd="ul" title="列表 - 项目">≡</button>
|
||||||
|
<button type="button" class="cmd" data-cmd="quote" title="引用 > 文字">❝</button>
|
||||||
⚠️ 为什么不做 contenteditable 那种"直接在渲染结果上编辑":
|
<button type="button" class="cmd" data-cmd="code" title="代码 ```"></></button>
|
||||||
那要引入一套 HTML↔Markdown 的双向转换与清洗(正是"引大库"的那条路),
|
<button type="button" class="cmd" data-cmd="link" title="链接 [文字](地址)">🔗</button>
|
||||||
而且富文本内容再存回 Markdown 时几乎必然丢格式或引入噪音。
|
<span class="cmd-sep"></span>
|
||||||
这里选择"**输入 Markdown + 实时预览**"—— 两个诉求都满足,
|
<span class="small muted">行首打 <code>/</code> 也能选命令</span>
|
||||||
数据始终是干净的 Markdown 纯文本(可导出、可搜索、可版本对比)。 */ ?>
|
|
||||||
<div class="note-modebar">
|
|
||||||
<span class="small muted">段落支持 Markdown:<code>**粗体**</code>
|
|
||||||
<code>*斜体*</code> <code># 标题</code> <code>- 清单</code> <code>> 引用</code>
|
|
||||||
<code>`代码`</code></span>
|
|
||||||
<label class="note-live">
|
<label class="note-live">
|
||||||
<input type="checkbox" id="noteLivePreview" checked>
|
<input type="checkbox" id="noteLivePreview" checked>
|
||||||
<span>实时预览</span>
|
<span>实时预览</span>
|
||||||
@@ -124,7 +292,7 @@ if (!$blocks) {
|
|||||||
<input type="file" id="noteImgInput" accept="image/*" hidden>
|
<input type="file" id="noteImgInput" accept="image/*" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* ---------------- 标签 + 共享 ---------------- */ ?>
|
<?php /* ---------------- 标签 ---------------- */ ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="noteTags">标签</label>
|
<label for="noteTags">标签</label>
|
||||||
@@ -142,25 +310,18 @@ if (!$blocks) {
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php /* ⚠️ v29:原来的「共享给全家」勾选框**删掉**了 ——
|
||||||
<label class="note-share-toggle">
|
共享改成"保存后在阅读态选人 + 选权限"(与资源一致,见第 9 条)。 */ ?>
|
||||||
<input type="checkbox" name="is_shared" value="1"
|
|
||||||
<?= !empty($editing['is_shared']) ? 'checked' : '' ?>>
|
|
||||||
<span>共享给全家(默认只有自己看得到)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
|
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
|
||||||
⇒ 附件区放在**整张表单的最下面**(标签与共享之后),与用户说的一致。
|
⇒ 附件区放在**整张表单的最下面**。
|
||||||
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
|
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
|
||||||
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。
|
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。 */ ?>
|
||||||
不采用"草稿附件 + 保存时认领"(图片块那套):附件不像图片那样内嵌在正文里,
|
|
||||||
草稿态文件在用户放弃保存后会变成谁看不见的垃圾;宁可让他保存完再传。 */ ?>
|
|
||||||
<?php if ($isEditing): ?>
|
<?php if ($isEditing): ?>
|
||||||
<div class="card card-pad" id="noteFilesCard">
|
<div class="card card-pad" id="noteFilesCard">
|
||||||
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
|
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
|
||||||
|
|
||||||
<?php /* 已有附件:可下载、可删 */ ?>
|
|
||||||
<?php if (!empty($editing['files'])): ?>
|
<?php if (!empty($editing['files'])): ?>
|
||||||
<div class="note-files">
|
<div class="note-files">
|
||||||
<?php foreach ((array)$editing['files'] as $f): ?>
|
<?php foreach ((array)$editing['files'] as $f): ?>
|
||||||
@@ -203,23 +364,25 @@ if (!$blocks) {
|
|||||||
|
|
||||||
<div class="note-actions">
|
<div class="note-actions">
|
||||||
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
|
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
|
||||||
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
|
<?php /* 取消:编辑已有笔记时回它的阅读态;新建时回列表 */ ?>
|
||||||
|
<a class="btn btn-ghost"
|
||||||
|
href="<?= e($isEditing ? url('notes/form', ['id' => (int)$editing['id']]) : url('notes')) ?>">取消</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($isEditing): ?>
|
<?php if ($isEditing): ?>
|
||||||
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
|
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
|
||||||
避免"每行一份 form"那种页面膨胀)。 */ ?>
|
避免"每行一份 form"那种页面膨胀)。 */ ?>
|
||||||
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
|
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
|
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
|
||||||
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
|
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
|
||||||
<script type="application/json" id="noteSeed"><?= json_encode(
|
<script type="application/json" id="noteSeed"><?= json_encode(
|
||||||
$blocks,
|
$blocks,
|
||||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
|
||||||
) ?></script>
|
) ?></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -93,7 +93,9 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
$firstImg = '';
|
$firstImg = '';
|
||||||
foreach ((array)$n['blocks'] as $b) {
|
foreach ((array)$n['blocks'] as $b) {
|
||||||
if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') {
|
if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') {
|
||||||
$firstImg = (string)$b['content'];
|
// ⚠️ 图片块内容可能是 `key|50`(v29 第 8 条:可调宽度)。
|
||||||
|
// 取 key 时要把 `|宽度` 去掉,否则拿它当 key 查会 404。
|
||||||
|
$firstImg = trim(explode('|', (string)$b['content'])[0]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +103,14 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<article class="note-card" data-note="<?= $nid ?>">
|
<article class="note-card" data-note="<?= $nid ?>">
|
||||||
<header class="note-head">
|
<header class="note-head">
|
||||||
<span class="note-meta">
|
<span class="note-meta">
|
||||||
<?php if (!empty($n['is_shared'])): ?>
|
<?php
|
||||||
<span class="note-share" title="全家可见">共享</span>
|
/* 🔴 v29 第 9 条:共享状态从"共享/私有"两态改成**按人数**显示 ——
|
||||||
|
共享已升级成"逐人 + 权限",只说"共享"已经说不清共享给了几个人。
|
||||||
|
$shareN 是路由一次查好的映射(别在循环里再查库)。 */
|
||||||
|
$sN = (int)($shareN[$nid] ?? 0);
|
||||||
|
?>
|
||||||
|
<?php if ($sN > 0): ?>
|
||||||
|
<span class="note-share" title="共享给 <?= $sN ?> 个人">共享 <?= $sN ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="note-private" title="只有自己可见">私有</span>
|
<span class="note-private" title="只有自己可见">私有</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -110,7 +118,7 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
</span>
|
</span>
|
||||||
<?php if ($mine && $canWrite): ?>
|
<?php if ($mine && $canWrite): ?>
|
||||||
<span class="note-ops">
|
<span class="note-ops">
|
||||||
<a class="note-op" href="<?= e(url('notes/form', ['id' => $nid])) ?>"
|
<a class="note-op" href="<?= e(url('notes/form', ['id' => $nid, 'edit' => 1])) ?>"
|
||||||
title="编辑这条笔记">改</a>
|
title="编辑这条笔记">改</a>
|
||||||
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
|
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
|
||||||
onsubmit="return confirm('删掉这条笔记?');">
|
onsubmit="return confirm('删掉这条笔记?');">
|
||||||
@@ -122,6 +130,7 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<?php /* 🔴 v29 第 8 条:卡片点进去是**阅读态**(不是编辑器) */ ?>
|
||||||
<a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
|
<a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
|
||||||
<?php if ($title !== ''): ?>
|
<?php if ($title !== ''): ?>
|
||||||
<h3 class="note-title"><?= e($title) ?></h3>
|
<h3 class="note-title"><?= e($title) ?></h3>
|
||||||
@@ -133,7 +142,13 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<?php /* 图片:等比缩放到卡片宽度,放在摘要下方(用户第 12 条原话) */ ?>
|
<?php /* 🔴 v29 第 6 条:「笔记,首页显示的时候,里面的图片,和行程的类似,
|
||||||
|
把图片按照比例缩放到固定的高度或者宽度,放在下面就行了。」
|
||||||
|
⇒ 卡片下方的缩略图改成**固定高度 + 等比缩放**(`object-fit: cover`
|
||||||
|
会裁切,用户看到的不再是完整的那张图;这里要的是"完整 + 整齐")。
|
||||||
|
⇒ 用 `object-fit: contain` + 固定高度:图片**完整**显示、
|
||||||
|
高度**整齐划一**,宽的那张两侧留白(比裁掉内容好,也比卡片高矮不一好)。
|
||||||
|
对比"行程"那边的做法(用户说"和行程的类似"):同一套观感。 */ ?>
|
||||||
<?php if ($firstImg !== ''): ?>
|
<?php if ($firstImg !== ''): ?>
|
||||||
<div class="note-thumb">
|
<div class="note-thumb">
|
||||||
<img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
|
<img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
|
||||||
|
|||||||
@@ -211,7 +211,18 @@ $renderGrid = function (array $list) use ($meId, $canMove): void {
|
|||||||
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
|
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
|
||||||
aria-label="更多操作" title="更多操作">⋯</button>
|
aria-label="更多操作" title="更多操作">⋯</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div><?php /* /.person-actions */ ?>
|
||||||
|
</div><?php /* /.person
|
||||||
|
🔴🔴 v29 第 3 条「名册页面完全错乱」的**真根因**就在这里:
|
||||||
|
这一行 `</div>`(闭合 `.person`)在 v29 重写平铺列表时**漏掉了**。
|
||||||
|
浏览器遇到"卡片没闭合"会**把下一张卡塞进上一张里**(DOM 修复行为),
|
||||||
|
于是每张卡的宽度被前一张的按钮吃掉 —— 实测第一张的 `.person-main`
|
||||||
|
只剩 24~151px(本该 ~800px),姓名与徽标全被挤碎,
|
||||||
|
整页看起来就是"错乱"。
|
||||||
|
⚠️ 这类"少一个闭合标签"的 bug,`php -l` 与静态断言**全绿**
|
||||||
|
(PHP 语法没错、PHP 的 if/endif 也平衡),只有真机量 DOM 才现形。
|
||||||
|
定位手法:量 `.person` 的 `children`,发现里面**有另一个 `.person`**
|
||||||
|
—— 嵌套的同类元素 = 上一张没收尾。 */ ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
Reference in New Issue
Block a user