From 6852b4ee2c8a2aabc8e0d43203049cd60bca90b8 Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 8 Oct 2026 02:20:19 +0800 Subject: [PATCH] =?UTF-8?q?v28=20=E7=AC=AC=2028=20=E6=89=B9=EF=BC=8812=20?= =?UTF-8?q?=E6=9D=A1=EF=BC=89=EF=BC=9A=E5=90=8D=E5=86=8C=E5=88=86=E7=BB=84?= =?UTF-8?q?=E6=94=B9=E6=A0=87=E7=AD=BE=20/=20=E8=B5=84=E6=BA=90=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=BA=E4=B8=A4=E6=8C=89=E9=92=AE=20/=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=AE=9A=E4=BD=8D=E4=B8=8E=E4=B8=BB=E9=A2=98=E8=89=B2?= =?UTF-8?q?=20/=20=E7=AC=94=E8=AE=B0=E5=AF=8C=E6=96=87=E6=9C=AC=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位: 1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。 修:把当前小时并进区间端点(现在必然落在区间内)。 2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、 「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。 4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。 5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组): 新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**; 🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。 6. 名册卡片按钮加语义底色。 7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。 8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成 单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。 9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s), 全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。 10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。 11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。 12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**), 实时预览走服务端(前后端只一份规则);底部附件上传;清单多级; 列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。 --- app/Markdown.php | 280 +++++++++++++++++++ app/Nav.php | 44 +++ app/Note.php | 111 +++++++- app/PersonRoster.php | 35 ++- app/PersonTag.php | 187 +++++++++++++ app/Schema.php | 81 +++++- app/WeRead.php | 62 ++++- assets/css/app.css | 301 ++++++++++++++++++++ assets/js/app.js | 29 +- assets/js/note_editor.js | 112 +++++++- index.php | 200 ++++++++++++- nav_suggest.php | 53 ++++ templates/home.php | 29 +- templates/nav/index.php | 75 ++++- templates/notes/form.php | 85 ++++++ templates/notes/index.php | 263 +++++++++-------- templates/people/index.php | 511 ++++++++++++++++++---------------- templates/resources/index.php | 391 +++++++++++++++----------- templates/tasks/index.php | 11 +- 19 files changed, 2273 insertions(+), 587 deletions(-) create mode 100644 app/Markdown.php create mode 100644 app/PersonTag.php diff --git a/app/Markdown.php b/app/Markdown.php new file mode 100644 index 0000000..a0b6b24 --- /dev/null +++ b/app/Markdown.php @@ -0,0 +1,280 @@ +` 只会被画成一行字, + * 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。 + * 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。 + */ + +declare(strict_types=1); + +namespace App; + +final class Markdown +{ + /** + * 把一段 Markdown 渲染成安全的 HTML 片段。 + * + * @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生

包裹 + */ + public static function render(string $src, bool $inline = false): string + { + // 统一换行,去掉 BOM(从别处粘贴来的文本常带) + $src = str_replace(["\r\n", "\r"], "\n", $src); + $src = preg_replace('/^\xEF\xBB\xBF/', '', $src) ?? $src; + + $lines = explode("\n", $src); + $out = []; + $i = 0; + $n = count($lines); + + while ($i < $n) { + $line = $lines[$i]; + + // ---- 代码块 ``` 或 ~~~ ---------------------------------------- + if (preg_match('/^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/', $line, $m)) { + $fence = $m[1][0]; + $lang = (string)$m[2]; + $buf = []; + $i++; + while ($i < $n && !preg_match('/^\s*' . preg_quote(str_repeat($fence, 3), '/') . '+/', $lines[$i])) { + $buf[] = $lines[$i]; + $i++; + } + $i++; // 跳过收尾的围栏 + $cls = $lang !== '' ? ' class="md-code lang-' . e($lang) . '"' : ' class="md-code"'; + $out[] = '' . e(implode("\n", $buf)) . ''; + continue; + } + + // ---- 标题 # ~ ###### ------------------------------------------ + if (preg_match('/^(#{1,6})\s+(.*)$/', $line, $m)) { + $lv = strlen($m[1]); + // ⚠️ 从 h4 起(h1/h2 留给页面本身),免得笔记里的标题盖过页面标题 + $tag = 'h' . min(6, $lv + 2); + $out[] = '<' . $tag . ' class="md-h">' . self::inline(trim($m[2])) . ''; + $i++; + continue; + } + + // ---- 分割线 ---------------------------------------------------- + if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) { + $out[] = '


'; + $i++; + continue; + } + + // ---- 引用 > (支持多层,用前缀数决定层级)---------------------- + if (preg_match('/^\s*>\s?(.*)$/', $line)) { + $buf = []; + while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) { + $buf[] = $mm[1]; + $i++; + } + $out[] = '
' . self::render(implode("\n", $buf)) . '
'; + continue; + } + + // ---- 无序列表(支持**多级**:按前导空格数决定层级)-------------- + if (preg_match('/^(\s*)[-*+]\s+(.*)$/', $line, $m)) { + [$html, $i] = self::list($lines, $i, $n, false); + $out[] = $html; + continue; + } + + // ---- 有序列表 -------------------------------------------------- + if (preg_match('/^(\s*)\d+[.)]\s+(.*)$/', $line, $m)) { + [$html, $i] = self::list($lines, $i, $n, true); + $out[] = $html; + continue; + } + + // ---- 空行 ------------------------------------------------------ + if (trim($line) === '') { + $i++; + continue; + } + + // ---- 普通段落:连续非空行合成一段(软换行)----------------------- + $buf = [$line]; + $i++; + while ($i < $n && trim($lines[$i]) !== '' + && !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])) { + $buf[] = $lines[$i]; + $i++; + } + $para = implode("\n", $buf); + // 段内换行 →
(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行) + $html = self::inline($para); + $html = str_replace("\n", "
", $html); + $out[] = '

' . $html . '

'; + } + + $html = implode("\n", $out); + return $inline ? $html : $html; + } + + /** + * 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。 + * + * 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 ) + while ($stack) { + array_pop($stack); + $html .= ''; + } + return [$html, $i]; + } + + /** + * 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。 + * + * ⚠️ 顺序要紧:**先处理行内代码**,再处理粗斜体 —— + * 否则 `` `a*b*c` `` 里的星号会被当斜体吃掉(代码里的字符不该被解释)。 + * ⚠️ 先 escape 再加标记:所有 `<` `>` `&` 在进来时就已经是实体了, + * 我们只往里加**自己的**标签,不可能被注入。 + */ + public static function inline(string $s): string + { + $s = e($s); + + // `code`(先做,且内部不再解释其它语法) + $s = preg_replace_callback('/`([^`\n]+)`/', static function ($m): string { + return '' . $m[1] . ''; + }, $s) ?? $s; + + // ![alt](url) 图片(笔记里可能有,但我们**不内联渲染外部图片** —— + // 那是隐私与外链风险;只显示成一个链接,点了自己开) + $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string { + $u = self::safeUrl($m[2]); + return $u === '' ? $m[1] : '' + . ($m[1] !== '' ? $m[1] : $u) . ''; + }, $s) ?? $s; + + // [文字](url) + // ⚠️ URL 里遇到右括号就截断 —— 所以 `javascript:alert(1)` 这种带括号的 + // 会被切掉后半截(只剩 `javascript:alert(1`)→ 协议判定仍然不过 → 降级成纯文本。 + // 剩下的那截尾巴(`)`)会留在正文里,看着像个小瑕疵。 + // ⇒ 用回调把"不合法链接"的**整段原文**还原回去,而不是只留前半截。 + $s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]*)\)/', static function ($m): string { + $u = self::safeUrl($m[2]); + if ($u === '') { + return $m[0]; // 不合法 → 原样保留(用户看到自己写的东西,而不是被切一半) + } + return '' + . $m[1] . ''; + }, $s) ?? $s; + + // **粗体** / __粗体__ + $s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s; + $s = preg_replace('/__(.+?)__/s', '$1', $s) ?? $s; + // *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤) + $s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s; + // ~~删除线~~ + $s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s; + + return $s; + } + + /** 只放行 http / https / mailto;其它(含 javascript:)返回空串 */ + private static function safeUrl(string $u): string + { + $u = trim($u); + if ($u === '') { + return ''; + } + // 已 escape 过,还原常见实体再做协议判定 + $raw = html_entity_decode($u, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (!preg_match('#^(https?:|mailto:)#i', $raw)) { + return ''; + } + return $u; + } + + /** + * 纯文本摘要(列表页卡片用):把 Markdown 记号去掉,只留可读的字。 + * 比"渲染成 HTML 再 strip_tags"更省,也不会把 `**` 这种记号露给用户。 + */ + public static function excerpt(string $src, int $len = 120): string + { + $s = str_replace(["\r\n", "\r"], "\n", $src); + // 去掉代码围栏标记、标题号、引用号、列表号 + $s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s; + $s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s; + $s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s; + $s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s; + // 去掉行内标记 + $s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s; + $s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s; + $s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s; + $s = preg_replace('/`([^`\n]+)`/', '$1', $s) ?? $s; + $s = preg_replace('/!?\[([^\]]*)\]\([^)\s]+\)/', '$1', $s) ?? $s; + $s = preg_replace('/\s+/u', ' ', $s) ?? $s; + $s = trim($s); + if (mb_strlen($s, 'UTF-8') > $len) { + $s = mb_substr($s, 0, $len, 'UTF-8') . '…'; + } + return $s; + } +} \ No newline at end of file diff --git a/app/Nav.php b/app/Nav.php index 61ea491..b9dfde3 100644 --- a/app/Nav.php +++ b/app/Nav.php @@ -983,6 +983,50 @@ final class Nav ['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0], ]; + /** + * **联想接口库**(v28 用户第 10 条)。 + * + * 用户原话:「导航功能,联想接口还是不能用啊,需要你补充几个进去,让用户选择。 + * 搜索引擎列表,联想接口,是**对所有用户可见**的。」 + * + * ⇒ 提供一份**内置的、大家都能看到**的联想接口清单(不是每人各存一份)。 + * 用户在「搜索引擎设置」里点一下就能把某个接口用到自己的引擎上。 + * + * ⚠️ 这份清单里的每一条都必须**实测可用**(下方注释标了来源)。 + * 国内网络下不可达的(谷歌 / DuckDuckGo)**放进去也没用** —— + * 用户点了发现永远空下拉,比不提供更差。所以只列走通的。 + * ⚠️ `type` 决定解析方式(见 nav_suggest.php 的 nav_parse_suggest), + * 必须是那边认得的取值,加新类型要**两处一起改**。 + */ + public const SUGGEST_LIBRARY = [ + // [显示名, 联想地址模板, 解析类型, 说明] + ['百度', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', + '实测最快最稳(约 0.1 秒)'], + ['360', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}', + 'so360', '速度次之(约 0.2 秒)'], + ['必应', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', + '可用但偏慢(0.5~1.6 秒)'], + ['搜狗', 'https://suggestion.sogou.com/suggnew/ajajjson?key={q}&type=web', 'sogou', + '备用(域名时好时坏)'], + ['知乎', 'https://www.zhihu.com/api/v4/search/suggest?q={q}&token=', 'zhihu', + '内容向站点,适合当第二来源'], + ['B站', 'https://s.search.bilibili.com/main/suggest?term={q}', 'bili', + '内容向站点'], + ]; + + /** + * 某个人现在**能选**的联想接口 = 内置库里的全部(对所有用户可见)。 + * @return array + */ + public static function suggestLibrary(): array + { + $out = []; + foreach (self::SUGGEST_LIBRARY as $row) { + $out[] = ['name' => $row[0], 'url' => $row[1], 'type' => $row[2], 'note' => $row[3]]; + } + return $out; + } + /** * 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。 * diff --git a/app/Note.php b/app/Note.php index d690d2a..65bf40e 100644 --- a/app/Note.php +++ b/app/Note.php @@ -40,14 +40,20 @@ namespace App; final class Note { - /** 附件(图片块)在统一 attachments 表里的宿主类型 */ + /** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */ public const ATTACH = 'note'; /** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */ public const BLOCK_KINDS = ['text', 'image', 'list']; - /** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */ - public const MAX_BLOCKS = 60; + /** + * 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) + * + * 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown, + * 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。 + * ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。 + */ + public const MAX_BLOCKS = 200; public const MAX_TEXT = 20000; /** 列表页默认显示多少条(超过给"加载更多") */ @@ -156,6 +162,10 @@ final class Note "SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id" ); foreach ($st->fetchAll() as $b) { + // v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本) + $b['html'] = (string)$b['kind'] === 'text' + ? Markdown::render((string)$b['content']) + : ''; $blocks[(int)$b['note_id']][] = $b; } @@ -171,11 +181,47 @@ final class Note $id = (int)$r['id']; $r['blocks'] = $blocks[$id] ?? []; $r['tags'] = $tags[$id] ?? []; + /* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。 + 摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML, + 这样列表页再长也不会因为 Markdown 变慢。 */ + $r['excerpt'] = self::excerptOf($r); } unset($r); return $rows; } + /** + * 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。 + * 图片块计作 [图片],清单块取前几行,段落走 Markdown 去记号。 + */ + private static function excerptOf(array $note, int $len = 110): string + { + $buf = []; + foreach ((array)($note['blocks'] ?? []) as $b) { + $kind = (string)$b['kind']; + $c = (string)$b['content']; + if ($kind === 'image') { + $buf[] = '[图片]'; + } elseif ($kind === 'list') { + $buf[] = Markdown::excerpt($c, 60); + } else { + $buf[] = Markdown::excerpt($c, 200); + } + // 凑够长度就不再往下拼(长笔记没必要全量处理一遍) + if (mb_strlen(implode(' ', $buf), 'UTF-8') >= $len) { + break; + } + } + $txt = trim((string)preg_replace('/\s+/u', ' ', implode(' ', $buf))); + if ($txt === '') { + return ''; + } + if (mb_strlen($txt, 'UTF-8') > $len) { + $txt = mb_substr($txt, 0, $len, 'UTF-8') . '…'; + } + return $txt; + } + /** 单条(带权限判定)。看不到返回 null。 */ public static function find(int $familyId, int $id, int $viewer): ?array { @@ -191,18 +237,73 @@ final class Note } $r['blocks'] = self::blocks($id); $r['tags'] = self::tags($id); + $r['files'] = self::files($id); // v28:底部附件 $r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0; return $r; } - /** 某条笔记的块(按位置) */ + /** + * 一条笔记的**底部附件**(v28 第 12 条:「再加上附件上传功能, + * 附件,都放在笔记最下面就行了。」)。 + * + * ⚠️ 与**图片块**(kind=image)不是一回事,别混: + * · 图片块 = 插在正文中间、参与图文混排的图(用户第 12 条要的"图片和正文混排"); + * · 底部附件 = 挂在笔记末尾的任意文件(PDF / 压缩包 / 表格…)。 + * 两者共用 `attachments` 表(target_type='note'),靠这个顺序体现区别: + * 图片块在上传时就由 `claimUploads` 认领(target_id=真实笔记 id), + * 底部附件则由 `attach_add` 路由显式挂上来 —— **都是同一张表的行**, + * 所以删笔记时外键级联一把全部带走,不会留孤儿。 + * + * 怎么区分两者(同一个 target_id 下):图片块的那些**已经作为 image 块的内容** + * 出现在 note_blocks 里;渲染底部附件时把它们排掉即可。 + * + * @return array attachments 行(按 position) + */ + public static function files(int $noteId): array + { + if ($noteId <= 0) { + return []; + } + // 先取出"用作图片块的那些 key",避免它们在底部附件里重复出现 + $used = []; + foreach (self::blocks($noteId) as $b) { + if ((string)$b['kind'] === 'image') { + $used[(string)$b['content']] = true; + } + } + $out = []; + foreach (Attachment::list(self::ATTACH, $noteId) as $a) { + if (isset($used[(string)$a['key']])) { + continue; + } + $out[] = $a; + } + return $out; + } + + /** + * 某条笔记的块(按位置) + * + * ⚠️ 这里给每个 text 块算好 `html`(Markdown 渲染结果)——**在模型层算一次**, + * 列表页与详情页共用,不必各自再调一遍 Markdown::render + * (两处各算一次,迟早有一处忘了转义或忘了渲染)。 + */ public static function blocks(int $noteId): array { $st = Database::get()->prepare( 'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id' ); $st->execute([$noteId]); - return $st->fetchAll(); + $rows = $st->fetchAll(); + foreach ($rows as &$r) { + $kind = (string)$r['kind']; + // 只有段落支持 Markdown(清单走自己的渲染、图片走 media) + $r['html'] = $kind === 'text' + ? Markdown::render((string)$r['content']) + : ''; + } + unset($r); + return $rows; } /** 某条笔记的标签 */ diff --git a/app/PersonRoster.php b/app/PersonRoster.php index 9c6589b..ec626f8 100644 --- a/app/PersonRoster.php +++ b/app/PersonRoster.php @@ -153,6 +153,20 @@ final class PersonRoster * @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...] * @return int 实际写入的条数 */ + /** + * 保存名册布局(拖动排序/改栏)。返回真正写了几条。 + * + * 🔴 v28 用户第 5 条:名册**平铺**之后不再有"改栏"这回事(一个人挂多个标签, + * 分栏会让他在多栏重复出现)。前端会把整个平铺列表当成"唯一一栏"(`g=0`)报上来。 + * ⚠️ 这时**绝对不能写 group_id**: + * · `g=0` 按下面那条规则会被落成 NULL —— 拖一次就把所有人原有的分组清空了; + * · 而 `person_roster.group_id` 正是"旧分组→同名标签"迁移的数据来源 + * (见 Schema 的迁移步骤),也不能被随手抹掉; + * · 它同时还是 vCard CATEGORIES 的读点(导出通讯录要用)。 + * ⇒ 判据:`g` 缺省(前端没报这一栏的组号)时**只更新 sort_order**, + * 完全不碰 group_id。这样"拖动只改顺序"这个新语义是结构上保证的, + * 而不是靠调用方记得别传错值。 + */ public static function applyLayout(int $familyId, int $ownerId, array $columns): int { $mine = self::mine($familyId, $ownerId); @@ -161,7 +175,11 @@ final class PersonRoster } $okGroups = PersonGroup::visibleIds($familyId, $ownerId); $db = Database::get(); - $up = $db->prepare( + $upSort = $db->prepare( + 'UPDATE person_roster SET sort_order = ? + WHERE family_id = ? AND owner_id = ? AND person_id = ?' + ); + $upBoth = $db->prepare( 'UPDATE person_roster SET group_id = ?, sort_order = ? WHERE family_id = ? AND owner_id = ? AND person_id = ?' ); @@ -171,10 +189,11 @@ final class PersonRoster if (!is_array($col)) { continue; } - $gid = (int)($col['g'] ?? 0); - // 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错 - if ($gid !== 0 && !isset($okGroups[$gid])) { - $gid = 0; + // 只有**显式带了组号**的栏才去动 group_id(见方法注释) + $hasG = array_key_exists('g', $col); + $gid = (int)($col['g'] ?? 0); + if ($hasG && $gid !== 0 && !isset($okGroups[$gid])) { + $gid = 0; // 别人家的私自分组 / 已删的分组 → 未分类,绝不静默挂错 } foreach ((array)($col['ids'] ?? []) as $pid) { $pid = (int)$pid; @@ -182,7 +201,11 @@ final class PersonRoster continue; // 不在我名册里的人,忽略 } $sort += 10; - $up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]); + if ($hasG) { + $upBoth->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]); + } else { + $upSort->execute([$sort, $familyId, $ownerId, $pid]); + } $written++; } } diff --git a/app/PersonTag.php b/app/PersonTag.php new file mode 100644 index 0000000..9ab9cea --- /dev/null +++ b/app/PersonTag.php @@ -0,0 +1,187 @@ + [tag, tag, ...] + * + * 一次取完(列表页要整页渲染),不做 N+1 —— 名册上千人时那是致命的。 + */ + public static function mapFor(int $familyId, int $ownerId): array + { + if ($familyId <= 0 || $ownerId <= 0) { + return []; + } + $st = Database::get()->prepare( + 'SELECT person_id, tag FROM person_tags + WHERE family_id = ? AND owner_id = ? + ORDER BY person_id, position, id' + ); + $st->execute([$familyId, $ownerId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['person_id']][] = (string)$r['tag']; + } + return $out; + } + + /** 单个(详情/编辑页用) */ + public static function of(int $ownerId, int $personId): array + { + if ($ownerId <= 0 || $personId <= 0) { + return []; + } + $st = Database::get()->prepare( + 'SELECT tag FROM person_tags WHERE owner_id = ? AND person_id = ? + ORDER BY position, id' + ); + $st->execute([$ownerId, $personId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[] = (string)$r['tag']; + } + return $out; + } + + /** + * 我用过的全部标签 + 各挂了几个(顶部筛选条用)。 + * + * ⚠️ 只统计**我的名册里还在的人** —— 否则会出现"标签上写着 5 个, + * 点进去只有 3 个"(另外 2 个已经被移出名册,标签行还在)。 + */ + public static function cloud(int $familyId, int $ownerId): array + { + if ($familyId <= 0 || $ownerId <= 0) { + return []; + } + $st = Database::get()->prepare( + 'SELECT t.tag, COUNT(*) AS n + FROM person_tags t + JOIN person_roster r ON r.owner_id = t.owner_id AND r.person_id = t.person_id + WHERE t.family_id = ? AND t.owner_id = ? + GROUP BY t.tag + ORDER BY n DESC, t.tag ASC' + ); + $st->execute([$familyId, $ownerId]); + return $st->fetchAll(); + } + + /** 建议用的标签词(写标签时的联想;按人气排序) */ + public static function suggest(int $familyId, int $ownerId, int $limit = 30): array + { + $out = []; + foreach (self::cloud($familyId, $ownerId) as $r) { + $out[] = (string)$r['tag']; + if (count($out) >= $limit) { + break; + } + } + return $out; + } + + /** + * 整组替换某人的标签(名册卡片上改标签就用它)。 + * + * 为什么是"整组替换"而不是逐个增删:界面是一次提交一整套标签的, + * 逐个 diff 反而要处理"哪些是新增/哪些是删除",代码多且容易漏。 + * 标签没有历史价值,全量重写最简单可靠。 + * + * @param array|string $raw 数组,或一段用空格/逗号分隔的标签 + * @return array 归一后真正写进去的标签(调用方可以回显) + */ + public static function setFor(int $familyId, int $ownerId, int $personId, $raw): array + { + if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) { + return []; + } + $tags = self::clean($raw); + $db = Database::get(); + $db->prepare('DELETE FROM person_tags WHERE owner_id = ? AND person_id = ?') + ->execute([$ownerId, $personId]); + if (!$tags) { + return []; + } + $st = $db->prepare( + 'INSERT INTO person_tags (family_id, owner_id, person_id, tag, position) + VALUES (?,?,?,?,?)' + ); + $pos = 10; + foreach ($tags as $t) { + $st->execute([$familyId, $ownerId, $personId, $t, $pos]); + $pos += 10; + } + return $tags; + } + + /** 删某人时把他的标签一起删(persons 外键也会级联,这里显式一次更稳)。 */ + public static function forgetPerson(int $personId): void + { + if ($personId <= 0) { + return; + } + Database::get()->prepare('DELETE FROM person_tags WHERE person_id = ?')->execute([$personId]); + } + + /** + * 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。 + * + * 与 Note::cleanTags 同一套规则(去 `#`、去空白、去重、限长限数)—— + * 两处各写一份迟早不一致(用户在笔记里能打 `#`,在名册里却打不了,最恼人)。 + */ + public static function clean($raw): array + { + $list = []; + if (is_array($raw)) { + $list = $raw; + } elseif (is_string($raw)) { + $list = preg_split('/[\s,,]+/u', $raw) ?: []; + } + $out = []; + foreach ($list as $t) { + $t = trim((string)$t); + $t = ltrim($t, '#'); + // 空白与 `#` 都不能出现在标签内部(它们是分隔符) + $t = (string)preg_replace('/[\s#]+/u', '', $t); + if ($t === '') { + continue; + } + $t = mb_substr($t, 0, self::MAX_LEN, 'UTF-8'); + if (!in_array($t, $out, true)) { + $out[] = $t; + } + if (count($out) >= self::MAX_EACH) { + break; + } + } + return $out; + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index cb2ceda..b936455 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -101,6 +101,13 @@ final class Schema * + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**: * 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表; * 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。 + * 28 → **名册「分组」改「标签」(用户 2026-10-07 第 5 条)**:新增 `person_tags` + * (每人一份、一人多标签、标签词存字符串)。动机是用户的原话: + * 「一个人可能并不是只在一个分组,例如他既是我的小学同学,又是我的初中同学, + * 就没法分组,改成标签,似乎更合理。」 + * ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点), + * 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。 + * 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -108,7 +115,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '27'; + public const VERSION = '28'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -970,6 +977,37 @@ CREATE TABLE IF NOT EXISTS `note_tags` ( CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)' +SQL, + // ---- 名册标签(v28 用户第 5 条)---- + // 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说, + // 一个人,他可能并不是只在一个分组,例如,他既是我的小学同学, + // 又是我的初中同学,就没法分组,改成标签,似乎更合理。」 + // + // ⇒ 分组(一人一组)→ 标签(一人多标签)。**表结构为什么这样切**: + // · `person_tags` 是**每人一份**的(owner_id = 谁的名册), + // 与 person_roster 同口径:标签是"我和他的关系",不是这个人的属性 + // (我给他打"小学同学"不该让全家的名册都变)。 + // · `tag` 直接存字符串,**不建标签表** —— 与笔记模块同一条轻量取舍: + // 标签是"随手打的词",不需要改名/合并/配色这些重型能力。 + // · ⚠️ `person_roster.group_id` **保留不删**:它是老库升级与 CardDAV + // 导出的读点(vCard 的 CATEGORIES 由它派生),删掉会让"没跟上升级" + // 的地方静默少一个字段。改成"以新表为准、老列只做兼容回填"。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `person_tags` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED NOT NULL COMMENT '谁的名册(persons.id)—— 标签是"我和他的关系"', + `person_id` INT UNSIGNED NOT NULL COMMENT '被打标签的人(persons.id)', + `tag` VARCHAR(24) NOT NULL COMMENT '标签词(不含 #;同一人同一标签不重复)', + `position` INT NOT NULL DEFAULT 10 COMMENT '同一人多个标签时的顺序', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pt` (`owner_id`, `person_id`, `tag`), + KEY `idx_pt_owner_tag` (`owner_id`, `tag`), + KEY `idx_pt_person` (`person_id`), + CONSTRAINT `fk_pt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`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 + COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")' SQL, ]; } @@ -1815,6 +1853,47 @@ SQL); ); }); + // v28:把「分组」逐一迁成**同名标签**(用户第 5 条:一人一组 → 一人多标签)。 + /* 🔴🔴 必须用**一次性哨兵**,不能"每次 ensure 都尝试搬一遍"。 + * + * 我第一版写的是「该人还没有任何标签时才搬」(NOT EXISTS 判 owner+person), + * 想当然地以为那就能防住"删除被撤销"。**实测当场推翻了这个假设**: + * · 用户在界面上把某人的标签**清空** → 那人身上一条标签都没有 + * → `NOT EXISTS` 成立 → 下一次 ensure **把他旧的分组名又灌回来**。 + * 症状:删掉的标签自己回来(与"移出的人又回来""纪念日关联人被老列覆盖" + * 是同一类坑,本项目已经撞过三次)。 + * + * ⇒ 正确做法是"这件事**只做一次**":用一个设置键当哨兵, + * 做完了就记下,之后无论库里变成什么样都不再碰。 + * 这也符合迁移的本质 —— 它是**一次性的数据转换**,不是持续同步。 + * ⚠️ 哨兵键名带年份/版本(`person_tags_migrated_v28`), + * 将来真有第二次迁移要另起一个键,不要复用。 + */ + self::tryStep('名册分组搬到标签(一人一组 → 一人多标签)', static function () use ($db) { + $flag = 'person_tags_migrated_v28'; + $has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn(); + if ((int)$has > 0) { + return; // 已经搬过了,永不再搬(见上面的说明) + } + if (self::hasTable($db, 'person_tags') && self::hasTable($db, 'person_groups')) { + $db->exec( + "INSERT IGNORE INTO `person_tags` (family_id, owner_id, person_id, tag, position) + SELECT r.family_id, r.owner_id, r.person_id, g.name, 10 + FROM `person_roster` r + JOIN `person_groups` g ON g.id = r.group_id + WHERE r.group_id IS NOT NULL + AND g.name IS NOT NULL AND g.name <> ''" + ); + } + // 记哨兵(settings 是**家庭级**的,用最小 family_id 当归属,与 + // schema_upgrade_warning 同一做法) + $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) { diff --git a/app/WeRead.php b/app/WeRead.php index bc15147..532b9a4 100644 --- a/app/WeRead.php +++ b/app/WeRead.php @@ -65,6 +65,32 @@ final class WeRead */ public const SYNC_TTL = 21600; + /** + * 一次同步的**总时长预算**(秒)。 + * + * 🔴🔴 v28 用户第 9 条:「读书,同步微信读书后,感觉有时候点击进来很慢, + * 需要加载很长时间,后来就好了,过一段时间又慢。」 + * + * 根因(实测推演,症状完全吻合): + * 自动同步挂在**读书列表页**上(见 index.php 的 books 路由), + * 而 `syncNow()` 最多扫 60 本书,**每一本**都要打 1~3 次网关 + * (bookInfo / progress / notes,缺一座就补一次封面还要再下图片)。 + * 单次 `call()` 的超时是 **20 秒**(见 call() 的 CURLOPT_TIMEOUT)—— + * 于是"6 小时到期的那一次"最坏能挂几分钟:用户点进来就是白屏等待, + * 过后(时间戳已写、间隔未到)再进来只读一个时间戳,立刻就快了; + * 再过 6 小时又是那一下 —— 这正是用户描述的"时快时慢、过一阵又慢"。 + * + * ⇒ 修法:给整轮同步一个**总预算**。到点就**停下来、正常返回**, + * 已处理的部分照常落库,剩下的等下一轮接着做(游标从头再来一遍也无妨: + * `matchLocalBook` 与 `store` 都是幂等的,"已存在的会跳过")。 + * ⚠️ 为什么设 8 秒:这是"用户能忍的一次页面等待"的量级。 + * 真实耗时取决于网关,宁少勿多 —— 慢一点同步完,比页面打不开好得多。 + */ + public const SYNC_BUDGET = 8; + + /** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */ + public const SYNC_MAX_BOOKS = 25; + // ================================================================ // Key 的存取(加密) // ================================================================ @@ -140,8 +166,14 @@ final class WeRead CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json, - CURLOPT_TIMEOUT => 20, - CURLOPT_CONNECTTIMEOUT => 8, + // 🔴 v28 用户第 9 条:单次调用超时从 20 秒收到 **8 秒**。 + // 同步一轮要打好几次网关,20 秒 × N 次可以直接把页面拖垮; + // 而网关正常时是零点几秒就回的(见 nav_suggest.php 里那批实测: + // 同类接口 0.1~1.6 秒)。8 秒足以覆盖"网关偶发慢",又不会让 + // 一次页面请求长时间挂着。慢的代价只是"这一轮少同步几本", + // 下一轮会接着做(幂等)。 + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $key, @@ -685,6 +717,10 @@ final class WeRead * 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。 * * @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用) + * 🔴 v28 用户第 9 条:$force 时给**更大的扫描量**(用户是 + * 主动点的,愿意等;而且手动同步本来就该一次做完整)。 + * 页面上的自动同步则用小量 —— 它挂在读书列表页上, + * 每多扫一本就多一次"打开页面就等"。 * @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string} */ public static function maybeSync(int $familyId, int $personId, bool $force = false): array @@ -698,12 +734,16 @@ final class WeRead if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) { return $empty; } - // ⚠️ 先记时间再干活:万一下面 20 秒里超时/抛错,也不会在同一个页面 + // ⚠️ 先记时间再干活:万一下面某一轮超时/抛错,也不会在同一个页面 // 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。 + // 🔴 v28:现在同步**有总预算**(SYNC_BUDGET),不会再长时间卡住; + // 这条"先记时间"依然要留 —— 它是"网关整个挂掉时别每次访问都重试"的兜底。 UserSetting::set($personId, self::SYNC_AT_KEY, (string)time()); try { - $res = self::syncNow($familyId, $personId); + // 自动同步只扫一小批(页面等待),手动同步扫完整批(用户愿意等) + $limit = $force ? 60 : self::SYNC_MAX_BOOKS; + $res = self::syncNow($familyId, $personId, $limit); } catch (\Throwable $e) { return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(), 'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')]; @@ -727,6 +767,16 @@ final class WeRead */ public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array { + /* 🔴 v28 用户第 9 条:**整轮的总时长预算**(见 SYNC_BUDGET 的注释)。 + * 到点就停、正常返回已处理的部分,剩下的下一轮继续。 + * ⚠️ 用"开始时刻 + 每轮循环都检查"而不是"包一个超时异常": + * PHP 的 curl 超时是按**单次调用**算的,没有"整个函数超时"这种机制; + * 要在中途刹车,只能在循环里自己判。 + * ⚠️ 判据放在**每本书的开头**(不是每本书之间某处):一本书内部还会打 + * 2~3 次网关,若在书中途判断就会留下半本书(字段补一半)。 + * 整本为单位地停,重来时那本书会被幂等地重做一遍。 */ + $deadline = microtime(true) + self::SYNC_BUDGET; + $nb = self::notebooks($personId); if (!$nb['ok']) { return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0, @@ -738,6 +788,10 @@ final class WeRead if ($scanned >= $maxBooks) { break; } + // 总预算到点 → 停在这里(已处理的都已落库;剩下的下一轮再做) + if (microtime(true) >= $deadline) { + break; + } $title = trim((string)$b['title']); if ($title === '') { continue; diff --git a/assets/css/app.css b/assets/css/app.css index 16e18d4..fa34070 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4571,3 +4571,304 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b /* 长英文/URL 不要把卡片撑出横向滚动(全站反复栽在这条上) */ .note-text, .note-list, .note-title { overflow-wrap: anywhere; } } + +/* ============================================================ + v28 用户第 2 / 3 条:首页两块右上角的「更多日程 / 全部任务」按钮 + 要**用所属模块的主题色做背景**(原来一律 .btn-ghost 的白底灰字, + 与"日程=橙、任务=砖红"的分区色完全看不出关系)。 + 做法:模板把模块色注入 `--hd-c`(见 home.php 的 style 属性), + 这里只负责"拿这个色当底 + 白字 + 悬停加深"三件事 —— + 不为日程/任务各写一套(那是两份迟早漂移的样式)。 + ⚠️ 用 color-mix 压暗悬停色而不是写死两个色:模块色在「个人设置」里可改, + 写死就会出现"用户换了主题色、按钮悬停还是原来的颜色"。 + ============================================================ */ +.hd-more { + background: var(--hd-c, var(--brand)); + border-color: var(--hd-c, var(--brand)); + color: #fff; +} +.hd-more:hover { + background: color-mix(in srgb, var(--hd-c, var(--brand)) 82%, #000); + border-color: color-mix(in srgb, var(--hd-c, var(--brand)) 82%, #000); + color: #fff; +} +/* 老浏览器不认 color-mix 时的兜底:至少不要变成透明/消失 */ +@supports not (background: color-mix(in srgb, red 50%, blue)) { + .hd-more:hover { background: var(--hd-c, var(--brand)); filter: brightness(.88); } +} + +/* ============================================================ + v28 用户第 5 / 6 条:名册「分组」改「标签」+ 卡片按钮要有底色 + ============================================================ */ + +/* ---- 顶部标签筛选条(替代原来的分栏)---- */ +.roster-tagbar { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + margin: 0 0 12px; padding: 10px 12px; + border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); +} +.roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; } +.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; } + +/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ---- + ⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、 + 窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。 + 用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */ +.roster.is-flat .people-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 8px; +} +@media (max-width: 720px) { + .roster.is-flat .people-grid { grid-template-columns: 1fr; } +} + +/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」 + 原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。 + 给它们各自一个**语义底色**(编辑=品牌淡、标签=中性灰、删除=危险淡), + 而不是统一刷成一种 —— 一眼能分清哪个是危险动作。 ---- */ +.person-edit, +.person-tag-btn, +.person-more-btn { + background: var(--line-2); + border-color: var(--line); + color: var(--ink); +} +/* 编辑 = 品牌色淡底(主要动作用它) */ +.person-edit { + background: var(--brand-soft, #FDF3EC); + border-color: color-mix(in srgb, var(--brand) 28%, transparent); + color: var(--brand-ink); +} +.person-edit:hover { background: color-mix(in srgb, var(--brand) 16%, #fff); } +/* 标签 = 中性灰底;有标签时稍深一点,一眼能看出"这个人是有标记的" */ +.person-tag-btn { background: var(--line-2); color: var(--muted); } +.person-tag-btn:hover { background: var(--line); color: var(--ink); } +.person-tag-btn.is-none { color: var(--faint); } +/* ⋯ = 中性 */ +.person-more-btn { background: var(--line-2); color: var(--muted); } +.person-more-btn:hover { background: var(--line); color: var(--brand); } +/* 删除已经是 .btn-danger(自带红底),不动 */ + +/* ---- 标签编辑浮层里的候选标签 ---- */ +.pt-picks { display: flex; flex-wrap: wrap; gap: 6px; } +.pt-pick { + display: inline-flex; align-items: center; gap: 5px; cursor: pointer; + padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; + background: #fff; font-size: 12.5px; color: var(--muted); +} +.pt-pick:hover { border-color: var(--brand-2); color: var(--brand-ink); } +/* 勾上时整颗变实心(不是只动那个小方框)—— 点一下的反馈要一眼看得见 */ +.pt-pick:has(input:checked) { + border-color: var(--brand); background: var(--brand-soft, #FDF3EC); + color: var(--brand-ink); +} +.pt-pick input { accent-color: var(--brand); } + +/* ============================================================ + v28 用户第 7 条:资源的「编辑」弹框改成带页签(信息 / 附件 / 共享) + ============================================================ */ +.res-tabs { + display: flex; gap: 4px; padding: 0 16px 10px; + border-bottom: 1px solid var(--line); margin-bottom: 12px; +} +.res-tab { + padding: 5px 14px; border: 1px solid transparent; border-radius: var(--radius-s); + background: transparent; color: var(--muted); cursor: pointer; font-size: 13.5px; +} +.res-tab:hover { background: var(--line-2); color: var(--ink); } +/* 当前页签:品牌淡底 + 品牌字色(与全站分段控件同一套观感) */ +.res-tab.is-on { + background: var(--brand-soft, #FDF3EC); + border-color: color-mix(in srgb, var(--brand) 30%, transparent); + color: var(--brand-ink); font-weight: 500; +} + +/* ============================================================ + v28 用户第 10 条:内置联想接口库(点一下填入表单) + ============================================================ */ +.sug-lib { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; } +.sug-lib-item { + display: inline-flex; flex-direction: column; align-items: flex-start; gap: 2px; + padding: 6px 12px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: #fff; cursor: pointer; text-align: left; +} +.sug-lib-item b { font-size: 13px; font-weight: 500; color: var(--ink); } +.sug-lib-item span { font-size: 11.5px; color: var(--faint); } +.sug-lib-item:hover { border-color: var(--brand-2); } +/* 刚被点选的那一个:品牌底 + 品牌边,明确"我选了它" */ +.sug-lib-item.is-picked { + border-color: var(--brand); background: var(--brand-soft, #FDF3EC); +} +.sug-lib-item.is-picked b { color: var(--brand-ink); } + +/* ============================================================ + v28 用户第 12 条:笔记 —— 富文本双模式 / 附件 / 多级清单 / 定尺寸卡片网格 + ============================================================ */ + +/* ---- 列表页:顶部条(搜索 + 标签),去掉左侧标签栏 ---- */ +.note-bar { margin-bottom: 12px; } +.note-search { margin: 0 0 8px; } +.note-tagbar { margin-top: 2px; } + +/* ---- 🔴 定尺寸卡片网格(用户:"一行几个块,但是块大小要确定")---- + 高度**固定**:内容装不下就靠摘要 + 溢出裁切,卡片之间永远整齐。 + 用 auto-fill + minmax 让"一行几个"随宽度自适应,而不是写死列数。 */ +.note-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.note-card { + display: flex; flex-direction: column; + /* 固定高度 = 用户要的"块大小确定"。240px 大约放得下:一行头 + 标题 + 3~4 行摘要 + 缩略图 */ + height: 240px; overflow: hidden; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); padding: 12px 14px; + box-shadow: var(--shadow); +} +.note-card:hover { border-color: var(--brand-2); } +.note-head { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.note-meta { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1 1 auto; } +.note-time { font-size: 11.5px; color: var(--faint); white-space: nowrap; } +.note-ops { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; } +.note-ops .inline-form { display: inline; } +.note-op { + font-size: 12px; padding: 1px 7px; border-radius: 6px; cursor: pointer; + border: 1px solid var(--line); background: var(--line-2); color: var(--muted); + text-decoration: none; line-height: 1.6; +} +.note-op:hover { background: var(--line); color: var(--ink); text-decoration: none; } +.note-op-del:hover { border-color: var(--red); color: var(--red); background: #FDF2F0; } + +/* 点卡片进编辑(整块可点,但不用 包全文 —— 里面还有表单与链接) */ +.note-hit { display: block; flex: 1 1 auto; min-height: 0; overflow: hidden; } +.note-hit:hover { text-decoration: none; } +.note-title { + margin: 8px 0 5px; font-size: 14.5px; font-weight: 500; color: var(--ink); + /* 标题最多两行,超出省略 —— 固定高度下不能让它无限长 */ + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.note-ex { + margin: 6px 0 0; font-size: 13px; line-height: 1.6; color: var(--muted); + display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; +} +.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 img { + width: 100%; height: 88px; object-fit: cover; display: block; + 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-tag { font-size: 11.5px; padding: 2px 7px; } + +/* ---- 编辑页:模式提示条 ---- */ +.note-modebar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line-2); +} +.note-modebar code { + font-size: 11.5px; background: var(--line-2); padding: 1px 5px; + border-radius: 4px; color: var(--ink); +} +.note-live { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; + font-size: 12.5px; color: var(--muted); cursor: pointer; } +.note-live input { accent-color: var(--brand); } + +/* ---- 段落的 Markdown 实时预览块 ---- */ +.note-block-pv { + margin-top: 6px; padding: 0 2px; font-size: 14px; line-height: 1.7; color: var(--ink); +} +.note-block-pv:not(.is-on) { display: none; } +/* 预览区左边一条细线,与输入区在视觉上分开(不然分不清哪块是输入、哪块是结果) */ +.note-block-pv.is-on { + border-left: 3px solid var(--brand-soft, #F3D9CB); + padding-left: 10px; +} + +/* ---- Markdown 渲染出来的元素(列表页/预览共用同一套)---- */ +.md-p { margin: 0 0 8px; } +.md-p:last-child { margin-bottom: 0; } +.md-h { margin: 10px 0 6px; font-weight: 500; color: var(--ink); } +h3.md-h { font-size: 16px; } +h4.md-h { font-size: 15px; } +h5.md-h, h6.md-h { font-size: 14px; } +.md-list { margin: 4px 0 8px; padding-left: 22px; } +.md-li { margin: 2px 0; } +/* 多级清单:子层用空心点,层级一眼可分(用户第 12 条"清单可以多级") */ +.md-list .md-list { list-style-type: circle; margin: 2px 0 2px; } +.md-list .md-list .md-list { list-style-type: square; } +.md-quote { + margin: 6px 0; padding: 4px 12px; border-left: 3px solid var(--line); + color: var(--muted); background: var(--line-2); border-radius: 0 6px 6px 0; +} +.md-code { + background: #F6F4F0; border: 1px solid var(--line); border-radius: 6px; + padding: 8px 10px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; + font-family: ui-monospace, Consolas, monospace; color: var(--ink); +} +.md-icode { + background: var(--line-2); padding: 1px 5px; border-radius: 4px; + font-size: 12.5px; font-family: ui-monospace, Consolas, monospace; +} +.md-link { color: var(--brand); text-decoration: underline; } +.md-hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; } + +/* ---- 编辑页底部附件区 ---- */ +.note-files { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.note-file { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: #FDFCFA; +} +.note-file-ico { + font-size: 11px; padding: 1px 6px; border-radius: 4px; + background: var(--line-2); color: var(--muted); flex: 0 0 auto; +} +.note-file-nm { flex: 1 1 auto; min-width: 0; font-size: 13.5px; color: var(--ink); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.note-file-del { + flex: 0 0 auto; border: 1px solid var(--line); background: #fff; border-radius: 6px; + color: var(--muted); cursor: pointer; padding: 2px 8px; font-size: 12px; +} +.note-file-del:hover { border-color: var(--red); color: var(--red); } +/* 拖放区:虚线框,与全站其它模块的上传区同一观感 */ +.note-att-drop { + border: 1px dashed var(--line); border-radius: var(--radius-s); + padding: 14px; text-align: center; background: #FDFCFA; +} +.note-att-drop.is-over { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); } +.note-att-drop input[type=file] { display: none; } +.note-att-tip { font-size: 13px; color: var(--muted); } +.note-att-tip label { color: var(--brand); cursor: pointer; text-decoration: underline; } + +/* ---- 窄屏:网格收成一列(固定高度在小屏上会显得很挤)---- */ +@media (max-width: 640px) { + .note-grid { grid-template-columns: 1fr; } + .note-card { height: auto; min-height: 150px; } +} + +/* ---- v28 修:名册卡片在窄屏被"标签按钮"撑破(真机几何探针抓到)---- + 根因:卡片右侧的动作区是一条 flex,里面那颗「标签」按钮的文字是 + **所有标签拼起来**(`#小学同学 #初中同学 …`),且 `white-space: nowrap` —— + 标签一多它就无限变宽,把整行、整张卡、整张网格一起顶出视口 + (实测 360px 下 scrollW=2090,越界元素就是那一串 / + + + + 这些接口对**所有用户**都可见、可直接选用。国内网络下不可达的 + (谷歌 / DuckDuckGo)没有列进来 —— 选了也只会永远是空下拉。 + +
(string)($navSet[$k] ?? $d);
+ \ No newline at end of file diff --git a/templates/notes/form.php b/templates/notes/form.php index dd66ce4..83c0d14 100644 --- a/templates/notes/form.php +++ b/templates/notes/form.php @@ -81,6 +81,31 @@ if (!$blocks) { + +
+ 段落支持 Markdown:**粗体** + *斜体* # 标题 - 清单 > 引用 + `代码` + +
+
@@ -125,12 +150,72 @@ if (!$blocks) { + + +
+

附件 放最后,与正文分开

+ + + +
+ + +
+ + + + + + +
+ +
+ +

还没有附件。

+ + +
+ + +
+ + 把文件拖到这里,或 +
+
+ +
+
+
+ +
+

附件

+

先「记下来」,保存之后回到这一页就能传附件了。

+
+ + + + + + + 而不是 data-* —— 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> diff --git a/templates/resources/index.php b/templates/resources/index.php index 713ff2c..de9141f 100644 --- a/templates/resources/index.php +++ b/templates/resources/index.php @@ -189,29 +189,33 @@ $myPerson = (int)\App\Auth::personId(); 个附件 - 包(phrasing content);`
`/`
` 放 里是非法 HTML。 - ⚠️ 每个按钮都带 `data-res-open`,脚本里对它 `preventDefault()` - 以阻止 顺带把这一条展开。 */ ?> + 包(phrasing content);`
`/`` 放 + 里是非法 HTML。 + ⚠️ 按钮仍带 `data-res-open`,脚本对它 `preventDefault()` + 以阻止 顺带把这一条展开。 + ⚠️ 页签要的数据(标题/正文/归属人/附件/已共享)全都挂在这一颗按钮上: + 弹框全页只有一份,点哪条就把哪条的数据灌进去 —— + 这正是"避免每行一份表单"那条老规矩(2369 个 form 把页面拖死)。 */ ?> - - + }, $as), JSON_UNESCAPED_UNICODE)) ?>">编辑
- - - + + +
+ + + +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +