v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式

其中 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**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
This commit is contained in:
2026-10-08 02:20:19 +08:00
parent 404b1afee0
commit 6852b4ee2c
19 changed files with 2273 additions and 587 deletions
+280
View File
@@ -0,0 +1,280 @@
<?php
/**
* 轻量 Markdown 渲染(v28 用户第 12 条)。
*
* 用户原话:「笔记功能,段落是需要支持富文本的,可以使用 markdown,但是需要使用
* 富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。就是说,
* 允许所见即所得编辑,也允许 markdown 编辑。」
*
* 为什么自己写而**不引入第三方库**(wyc 2026-10-07 已确认):
* · 本项目全站只有这一处要 Markdown,为一个功能引一个几百 KB~数 MB 的编辑器,
* 与"轻量级"直接冲突(Semi 那 4 MB 已经在按需加载了,不该再加一个);
* · 我们支持的语法是**有界的**(标题 / 粗斜体 / 行内代码 / 代码块 / 列表 /
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
* (不会在某次依赖升级后行为漂移)。
*
* 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`,
* 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字,
* 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。
* 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。
*/
declare(strict_types=1);
namespace App;
final class Markdown
{
/**
* 把一段 Markdown 渲染成安全的 HTML 片段。
*
* @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生 <p> 包裹
*/
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[] = '<pre' . $cls . '><code>' . e(implode("\n", $buf)) . '</code></pre>';
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[] = '<hr class="md-hr">';
$i++;
continue;
}
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
if (preg_match('/^\s*>\s?(.*)$/', $line)) {
$buf = [];
while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) {
$buf[] = $mm[1];
$i++;
}
$out[] = '<blockquote class="md-quote">' . self::render(implode("\n", $buf)) . '</blockquote>';
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);
// 段内换行 → <br>(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行)
$html = self::inline($para);
$html = str_replace("\n", "<br>", $html);
$out[] = '<p class="md-p">' . $html . '</p>';
}
$html = implode("\n", $out);
return $inline ? $html : $html;
}
/**
* 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。
*
* 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 <ul>,
* 更浅就逐层关掉。这样"手工敲两个空格"也能自然形成层级,
* 不需要用户去记"必须用 4 个空格"。
*
* @return array{0:string,1:int} [html, 新行号]
*/
private static function list(array $lines, int $i, int $n, bool $ordered): array
{
$html = '';
$stack = []; // 每层的缩进宽度
$tag = $ordered ? 'ol' : 'ul';
while ($i < $n) {
$line = $lines[$i];
$pat = $ordered ? '/^(\s*)\d+[.)]\s+(.*)$/' : '/^(\s*)[-*+]\s+(.*)$/';
if (!preg_match($pat, $line, $m)) {
// 非列表行:若是空行且后面还有更深的列表项,继续;否则结束
if (trim($line) === '') {
$i++;
continue;
}
break;
}
$indent = strlen(str_replace("\t", ' ', $m[1]));
$content = $m[2];
if (!$stack) {
$html .= '<' . $tag . ' class="md-list">';
$stack[] = $indent;
} elseif ($indent > end($stack)) {
// 更深 → 开新的一层(塞进上一个 <li> 里,成为它的子列表)
$html .= '<' . $tag . ' class="md-list">';
$stack[] = $indent;
} elseif ($indent < end($stack)) {
// 更浅 → 逐层关掉
while ($stack && $indent < end($stack)) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
if ($stack) {
$html .= '</li>';
}
} else {
$html .= '</li>';
}
$html .= '<li class="md-li">' . self::inline(trim($content));
$i++;
}
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
while ($stack) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
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 '<code class="md-icode">' . $m[1] . '</code>';
}, $s) ?? $s;
// ![alt](url) 图片(笔记里可能有,但我们**不内联渲染外部图片** ——
// 那是隐私与外链风险;只显示成一个链接,点了自己开)
$s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string {
$u = self::safeUrl($m[2]);
return $u === '' ? $m[1] : '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
. ($m[1] !== '' ? $m[1] : $u) . '</a>';
}, $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 '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
. $m[1] . '</a>';
}, $s) ?? $s;
// **粗体** / __粗体__
$s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s) ?? $s;
$s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s) ?? $s;
// *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤)
$s = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $s) ?? $s;
// ~~删除线~~
$s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $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;
}
}