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
+85
View File
@@ -81,6 +81,31 @@ if (!$blocks) {
</div>
</div>
<?php /* 🔴 v28 用户第 12 条:「段落是需要支持富文本的,可以使用 markdown,
但是需要使用富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。
就是说,允许所见即所得编辑,也允许 markdown 编辑。」
⇒ 两件事:
① **每个段落块下方实时显示渲染结果**("所见即所得")——
写 Markdown 的同时就能看到它长什么样,不必切来切去;
② 一个**全局"源码 / 预览"开关**:想专心改 Markdown 源码时,
把实时预览关掉(长文档下预览会占很多地方,也会让人分心)。
⚠️ 为什么不做 contenteditable 那种"直接在渲染结果上编辑":
那要引入一套 HTML↔Markdown 的双向转换与清洗(正是"引大库"的那条路),
而且富文本内容再存回 Markdown 时几乎必然丢格式或引入噪音。
这里选择"**输入 Markdown + 实时预览**"—— 两个诉求都满足,
数据始终是干净的 Markdown 纯文本(可导出、可搜索、可版本对比)。 */ ?>
<div class="note-modebar">
<span class="small muted">段落支持 Markdown:<code>**粗体**</code>
<code>*斜体*</code> <code># 标题</code> <code>- 清单</code> <code>&gt; 引用</code>
<code>`代码`</code></span>
<label class="note-live">
<input type="checkbox" id="noteLivePreview" checked>
<span>实时预览</span>
</label>
</div>
<div id="noteBlockList" class="note-blocks"></div>
<?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?>
@@ -125,12 +150,72 @@ if (!$blocks) {
</label>
</div>
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
⇒ 附件区放在**整张表单的最下面**(标签与共享之后),与用户说的一致。
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。
不采用"草稿附件 + 保存时认领"(图片块那套):附件不像图片那样内嵌在正文里,
草稿态文件在用户放弃保存后会变成谁看不见的垃圾;宁可让他保存完再传。 */ ?>
<?php if ($isEditing): ?>
<div class="card card-pad" id="noteFilesCard">
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
<?php /* 已有附件:可下载、可删 */ ?>
<?php if (!empty($editing['files'])): ?>
<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>
<button type="button" class="note-file-del" data-note-file-del="<?= (int)$f['id'] ?>"
title="删掉这个附件">✕</button>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint" id="noteFilesEmpty">还没有附件。</p>
<?php endif; ?>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('notes/attach_add')) ?>" id="noteAttForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop">
<input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>
</div>
<div class="btn-row">
<button class="btn btn-ghost btn-sm" type="submit">上传所选文件</button>
</div>
</form>
</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<p class="empty-hint">先「记下来」,保存之后回到这一页就能传附件了。</p>
</div>
<?php endif; ?>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
</div>
</form>
<?php if ($isEditing): ?>
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
避免"每行一份 form"那种页面膨胀)。 */ ?>
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
</form>
<?php endif; ?>
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode(
+129 -134
View File
@@ -1,16 +1,23 @@
<?php
/**
* 个人笔记 · 列表(v27 用户 2026-10-07)。
* 个人笔记 · 列表(v27 建;v28 用户第 12 条重构)。
*
* 形态参考 **flomo**:一条一张卡片、按时间倒序的流;顶部一个搜索框;
* 侧边一列标签云(点一下只看这个标签)。
* 「图文混排」在**卡片里就看得见**(图以缩略图排在文字之下),
* 不必点进去 —— 笔记是"回看"的东西,回看时应该一眼看到内容。
* 🔴 v28 第 12 条原话:「笔记列表页面,要注意大小,要么按照列表展示,要么按照块展示,
* 一行几个块,但是块大小要确定,如果显示不下,就显示简要信息,图片缩放后,放在下面。
* 这样看起来更美观。标签这个侧边栏,我感觉可以不要。」
*
* 分块只在**编辑页**体现(卡内可加段/图/清单并上下移);列表页只做**只读渲染**,
* 这是刻意的取舍:列表页可编辑就得为每张卡片配一套 JS 状态,与"轻量级"冲突。
* ⇒ 两处改动:
* ① **定尺寸卡片网格**(一行多个)—— 卡片高度固定,装不下就显示摘要,
* 图片等比缩放在卡片下方。原来是一列长卡片(一条占整屏),列表页很长、很难扫。
* ② **去掉左侧标签栏** —— 标签筛选改成顶部一条细筛选条(占位小得多),
* 宽度全给卡片。"可以不要"是用户的判断,照做。
*
* @param array $rows 我能看到的笔记(带 blocks / tags / is_mine)
* ⚠️ 卡片高度**固定**(CSS 里给固定高度 + 内容裁切),这是用户明确要求
* "块大小要确定"。所以卡片里不能放"会撑高的东西":
* · 正文只放**摘要**(不渲染全部块);
* · 图片只放第一张缩略图(按卡片宽度等比缩放,不会把卡片顶高)。
*
* @param array $rows 我能看到的笔记(带 blocks / tags / excerpt / is_mine)
* @param array $tagCloud 标签 + 条数
* @param string $keyword 搜索词
* @param string $tag 当前筛的标签
@@ -19,7 +26,6 @@
* @param int $limit 列表默认条数
*/
$canWrite = \App\Auth::canWrite();
$myPerson = (int)\App\Auth::personId();
?>
<div class="page-head">
<h1>笔记</h1>
@@ -33,136 +39,125 @@ $myPerson = (int)\App\Auth::personId();
</div>
</div>
<div class="note-layout">
<?php /* ---------------- 左侧:标签云 ---------------- */ ?>
<aside class="note-side">
<div class="card card-pad">
<h2 class="cinema-h2">标签</h2>
<?php if (!$tagCloud): ?>
<p class="empty-hint">还没有标签。写笔记时加上 <code>#标签</code> 就会出现在这里。</p>
<?php /* ---------------- 顶部:搜索 + 标签筛选(v28 去掉了左侧标签栏)---------------- */ ?>
<div class="card card-pad note-bar">
<form class="search note-search" method="get" action="index.php">
<input type="hidden" name="r" value="notes">
<?php if ($tag !== ''): ?>
<input type="hidden" name="tag" value="<?= e($tag) ?>">
<?php endif; ?>
<label class="sr-only" for="noteKw">搜索笔记</label>
<input class="input" id="noteKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文 / 标签" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== '' || $tag !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if ($tagCloud): ?>
<div class="note-tags note-tagbar">
<a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b>
</a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tag === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('notes', ['tag' => (string)$t['tag']])) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if (!$rows): ?>
<div class="card card-pad">
<p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?>
没有匹配的笔记。
<?php elseif ($canWrite): ?>
还没有笔记。点右上角「写笔记」随手记一条吧。
<?php else: ?>
<div class="note-tags">
<a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b>
</a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tag === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('notes', ['tag' => (string)$t['tag']])) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
</div>
还没有笔记。
<?php endif; ?>
</div>
</aside>
<?php /* ---------------- 右侧:卡片流 ---------------- */ ?>
<div class="note-main">
<div class="card card-pad note-search-card">
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="notes">
<?php if ($tag !== ''): ?>
<input type="hidden" name="tag" value="<?= e($tag) ?>">
<?php endif; ?>
<label class="sr-only" for="noteKw">搜索笔记</label>
<input class="input" id="noteKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文 / 标签,如「读书」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== '' || $tag !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">清除</a>
<?php endif; ?>
</form>
</div>
<?php if (!$rows): ?>
<div class="card card-pad">
<p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?>
没有匹配的笔记。
<?php elseif ($canWrite): ?>
还没有笔记。点右上角「写笔记」随手记一条吧。
<?php else: ?>
还没有笔记。
<?php endif; ?>
</p>
</div>
<?php else: ?>
<?php foreach ($rows as $n): ?>
<?php $mine = !empty($n['is_mine']); ?>
<article class="card card-pad note-card">
<header class="note-head">
<div class="note-meta">
<?php /* 头像 + 作者:共享笔记要一眼看出是谁写的 */ ?>
<img class="note-ava" src="<?= e(avatar_url($n['avatar'] ?? null)) ?>" alt="">
<span class="note-who"><?= e((string)$n['author_name']) ?></span>
<?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span>
<?php else: ?>
<span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?>
<?php /* 只显示到分钟、且把 T 换成空格后的形态(全站惯例:不引第三方时间库) */ ?>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</div>
<?php if ($mine && $canWrite): ?>
<div class="note-ops">
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes/form', ['id' => (int)$n['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
</form>
</div>
</p>
</div>
<?php else: ?>
<?php /* 定尺寸网格:一行多个,卡片高度固定(用户第 12 条"块大小要确定") */ ?>
<div class="note-grid">
<?php foreach ($rows as $n): ?>
<?php
$mine = !empty($n['is_mine']);
$nid = (int)$n['id'];
$title = trim((string)$n['title']);
// 卡片上的第一张图(等比缩放在卡片下方)—— 只取一张,多了会把卡片顶高
$firstImg = '';
foreach ((array)$n['blocks'] as $b) {
if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') {
$firstImg = (string)$b['content'];
break;
}
}
?>
<article class="note-card" data-note="<?= $nid ?>">
<header class="note-head">
<span class="note-meta">
<?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span>
<?php else: ?>
<span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?>
</header>
<?php if (trim((string)$n['title']) !== ''): ?>
<h3 class="note-title"><?= e((string)$n['title']) ?></h3>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</span>
<?php if ($mine && $canWrite): ?>
<span class="note-ops">
<a class="note-op" href="<?= e(url('notes/form', ['id' => $nid])) ?>"
title="编辑这条笔记">改</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<button type="submit" class="note-op note-op-del" title="删除这条笔记">删</button>
</form>
</span>
<?php endif; ?>
</header>
<?php /* 块渲染:文字段 / 图片 / 清单 —— 「图文混排」就在这儿 */ ?>
<div class="note-body">
<?php foreach ((array)$n['blocks'] as $b): ?>
<?php $kind = (string)$b['kind']; $c = (string)$b['content']; ?>
<?php if ($kind === 'image'): ?>
<a class="note-img" href="<?= e(media_url($c)) ?>"
data-prev="image" data-prev-src="<?= e(media_url($c)) ?>">
<img src="<?= e(media_url($c)) ?>" alt="笔记图片" loading="lazy">
</a>
<?php elseif ($kind === 'list'): ?>
<ul class="note-list">
<?php foreach (preg_split('/\R/u', $c) ?: [] as $line): ?>
<?php $line = trim((string)$line); ?>
<?php if ($line !== ''): ?>
<li><?= e($line) ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="note-text"><?= nl2br(e($c)) ?></p>
<?php endif; ?>
<a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
<?php if ($title !== ''): ?>
<h3 class="note-title"><?= e($title) ?></h3>
<?php endif; ?>
<?php if (trim((string)($n['excerpt'] ?? '')) !== ''): ?>
<p class="note-ex"><?= e((string)$n['excerpt']) ?></p>
<?php elseif ($title === ''): ?>
<p class="note-ex note-ex-empty">(这条笔记没有正文)</p>
<?php endif; ?>
</a>
<?php /* 图片:等比缩放到卡片宽度,放在摘要下方(用户第 12 条原话) */ ?>
<?php if ($firstImg !== ''): ?>
<div class="note-thumb">
<img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
</div>
<?php endif; ?>
<?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline">
<?php foreach ((array)$n['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">
#<?= e((string)$t) ?>
</a>
<?php endforeach; ?>
</div>
<?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline">
<?php foreach ((array)$n['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">
#<?= e((string)$t) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more">
<a class="btn btn-ghost" href="<?= e(url('notes', ['all' => 1, 'keyword' => $keyword, 'tag' => $tag])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
</div>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</div>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more">
<a class="btn btn-ghost" href="<?= e(url('notes', ['all' => 1, 'keyword' => $keyword, 'tag' => $tag])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
</div>
<?php endif; ?>
<?php endif; ?>