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])) . '' . $tag . '>';
+ $i++;
+ continue;
+ }
+
+ // ---- 分割线 ----------------------------------------------------
+ if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) {
+ $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); + // 段内换行 →
' . $html . '
'; + } + + $html = implode("\n", $out); + return $inline ? $html : $html; + } + + /** + * 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。 + * + * 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新' . $m[1] . '';
+ }, $s) ?? $s;
+
+ //  图片(笔记里可能有,但我们**不内联渲染外部图片** ——
+ // 那是隐私与外链风险;只显示成一个链接,点了自己开)
+ $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', '