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:
+19
-10
@@ -824,8 +824,16 @@
|
||||
|
||||
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
|
||||
|
||||
/* 🔴 v28 用户第 5 条:名册从「分栏」改成**平铺**(一个人可挂多个标签,
|
||||
分栏会让他在多栏里重复出现)。拖动因此只剩"排序"这一件事。
|
||||
⇒ 把整个 `.roster` 当成**唯一一栏**返回:下游的 placeAt / save / refreshCounts
|
||||
全都按"栏"来写,只要这里返回一个元素,它们不用改就能继续工作
|
||||
(比重写三条逻辑安全得多,也不会漏掉其中一条)。
|
||||
⚠️ 返回的是 `[roster]` 而不是 `.people-grid`:placeAt 里会 `block.querySelector('.people-grid')`,
|
||||
所以仍要有一个带 `.people-grid` 的容器;模板里 roster 直接包含 .people-grid。 */
|
||||
function blocks() {
|
||||
return roster.querySelectorAll('.group-block');
|
||||
var flat = roster.querySelector('.people-grid');
|
||||
return flat ? [roster] : [];
|
||||
}
|
||||
|
||||
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
|
||||
@@ -848,19 +856,16 @@
|
||||
* 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。
|
||||
*/
|
||||
|
||||
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
|
||||
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的。
|
||||
* ⚠️ v28:平铺之后只剩"一栏"(就是 .roster 本身),它**没有 .group-n 也没有
|
||||
* data-group**,所以下面这些查询都会拿到 null —— 全部做了存在性判断,
|
||||
* 并且**绝不能**再把 `b.hidden` 设成"没人就隐藏":
|
||||
* 那会把整张名册(roster 自己)藏起来。平铺下没有"空栏"这个概念。 */
|
||||
function refreshCounts() {
|
||||
each(blocks(), function (b) {
|
||||
var n = b.querySelectorAll('.people-grid .person').length;
|
||||
var nEl = b.querySelector('.group-n .n');
|
||||
if (nEl) { nEl.textContent = String(n); }
|
||||
b.classList.toggle('is-empty', n === 0);
|
||||
// 🔴 empty 的栏被 filter.js 标了 hidden(筛完没人剩就整栏收起)。
|
||||
// 拖了一张卡进来之后必须**把 hidden 也清掉** —— 只去掉 is-empty 没用:
|
||||
// 作者样式里 .group-block 没有写 display,于是浏览器默认的
|
||||
// [hidden]{display:none} 照样生效,那一栏连同刚放进去的人一起看不见
|
||||
// (症状就是"拖到后面的分组里,松手什么都没有")。
|
||||
b.hidden = (n === 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -887,7 +892,11 @@
|
||||
var pid = parseInt(c.getAttribute('data-person'), 10);
|
||||
if (pid > 0) { ids.push(pid); }
|
||||
});
|
||||
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, ids: ids });
|
||||
/* ⚠️ v28:平铺之后**不报 `g`** —— 见 PersonRoster::applyLayout 的注释:
|
||||
报了组号(哪怕是 0)就会覆盖 group_id,拖一次就把所有人的旧分组清空,
|
||||
而那是"旧分组→同名标签"迁移的数据源、也是 vCard 的读点。
|
||||
不报 `g` = 只更新顺序,这正是"拖动只改先后"的新语义。 */
|
||||
cols.push({ ids: ids });
|
||||
});
|
||||
|
||||
var body = new URLSearchParams();
|
||||
|
||||
+108
-4
@@ -73,15 +73,73 @@
|
||||
ta.rows = kind === 'list' ? 4 : 3;
|
||||
ta.value = content || '';
|
||||
ta.placeholder = kind === 'list'
|
||||
? '一行一项,回车换行'
|
||||
: (kind === 'text' ? '写点什么…' : '');
|
||||
// 清单块自动在每行前加「· 」的视觉提示(内容本身不加,保持纯文本)
|
||||
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
|
||||
? '一行一项;行首缩进两个空格 = 子项(可多级)'
|
||||
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
|
||||
body.appendChild(ta);
|
||||
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。
|
||||
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。
|
||||
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移
|
||||
(预览是粗体、保存后不是),而用户看到的就是预览。
|
||||
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大,
|
||||
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */
|
||||
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。
|
||||
段落:原文直接渲染。
|
||||
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
|
||||
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
|
||||
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
|
||||
不必为清单再写一套层级解析(两套必然漂移)。 */
|
||||
if (kind === 'text' || kind === 'list') {
|
||||
var pv = document.createElement('div');
|
||||
pv.className = 'note-block-pv';
|
||||
pv.setAttribute('data-pv', '1');
|
||||
body.appendChild(pv);
|
||||
}
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
|
||||
var pvTimer = 0;
|
||||
function renderPreview(block) {
|
||||
var pv = block.querySelector('[data-pv]');
|
||||
var ta = block.querySelector('textarea');
|
||||
if (!pv || !ta) { return; }
|
||||
var box = document.getElementById('noteLivePreview');
|
||||
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
||||
var md = ta.value;
|
||||
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
||||
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级
|
||||
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();
|
||||
fd.append('md', md);
|
||||
if (tok) { fd.append('_csrf', tok.value); }
|
||||
fetch('index.php?r=notes/preview', {
|
||||
method: 'POST', body: fd, credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
||||
.then(function (d) {
|
||||
if (!d || !d.ok) { return; }
|
||||
// ⚠️ 响应回来时用户可能已经又改了:只在内容仍一致时才贴上去
|
||||
// (否则会把"旧的渲染结果"盖在"新的输入"下面,看着像卡住了)
|
||||
if (ta.value !== md) { return; }
|
||||
pv.innerHTML = d.html;
|
||||
pv.classList.add('is-on');
|
||||
}).catch(function () { /* 预览失败不打扰用户,正文照旧可保存 */ });
|
||||
}
|
||||
function schedulePreview(block) {
|
||||
window.clearTimeout(pvTimer);
|
||||
pvTimer = window.setTimeout(function () { renderPreview(block); }, 350);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 序列化:从 DOM 读回块数组,写进隐藏域
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -210,7 +268,48 @@
|
||||
});
|
||||
|
||||
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
|
||||
list.addEventListener('input', serialize);
|
||||
// + 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 源码时更清爽)。 */
|
||||
var liveBox = document.getElementById('noteLivePreview');
|
||||
if (liveBox) {
|
||||
liveBox.addEventListener('change', function () {
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
||||
function (b) {
|
||||
if (liveBox.checked) { renderPreview(b); }
|
||||
else {
|
||||
var pv = b.querySelector('[data-pv]');
|
||||
if (pv) { pv.innerHTML = ''; pv.classList.remove('is-on'); }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 底部附件:删除(v28 第 12 条)----------------
|
||||
⚠️ 用全页唯一的那一份 #noteFileDelForm(与项目其它模块同一做法):
|
||||
每个附件一份 form 会在附件多时把页面撑爆。 */
|
||||
var fileDelForm = document.getElementById('noteFileDelForm');
|
||||
if (fileDelForm) {
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target && e.target.closest ? e.target.closest('[data-note-file-del]') : null;
|
||||
if (!b) { return; }
|
||||
if (!confirm('删掉这个附件?')) { return; }
|
||||
var inp = fileDelForm.querySelector('input[name="id"]');
|
||||
if (inp) { inp.value = b.getAttribute('data-note-file-del'); }
|
||||
fileDelForm.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// 提交前再序列化一次(双保险)
|
||||
form.addEventListener('submit', serialize);
|
||||
@@ -226,6 +325,11 @@
|
||||
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
||||
serialize();
|
||||
|
||||
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
||||
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
||||
function (b) { renderPreview(b); });
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 标签联想:点一下把 #标签 追加进输入框
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user