Files
FamilyBook/assets/js/task_panel.js
T
laoyang 7a108396ab 任务面板首次打开时截止控件没升级成 AntD (#75)
用户:「空间有了,但是不是我想要的空间,这个空间需要换。」
经确认「空间」=「控件」,现象是五个原生下拉(年/月/日/时/分),
期望 AntD 弹层日历。

真根因:首次打开面板走 entry_popup.js 的 loadPanel(),
它只 innerHTML + runInlineScripts,**没有补挂 AntD**。

上一条 #74 我在 task_panel.js 的 paint() 里加了 fbBootAntd(),
但 paint() 只在"面板里做了动作之后重画"时才跑
(点状态/挪看板/加子任务/发评论/传附件 → act() → 回来 paint())。
**首次打开不经过 paint()** ⇒ 用户第一眼看到的是从未升级过的原生下拉。
(先点一下「已完成」再点回来就会看到 AntD 挂上了 —— 这种"时好时坏"最迷惑。)

为什么 bootAntd 首屏也没兜住:
  if (!hosts.length && !dateRanges.length && !dates.length && !singles.length) return;
首页首屏任务卡里没有 [data-dt-antd-single](那控件要打开面板才有)
⇒ 首屏调用时直接 return。

修法:
① entry_popup.js loadPanel():插入 HTML 后调 fbBootAntd()
② 顺带修第二个 bug:bindDue() 原来也只在 paint() 里调,
   首次打开不绑定 ⇒ 选完截止时间 change 没人听 ⇒ **不会保存**。
   ⇒ 把 bindDue 暴露成 window.fbTaskPanelBindDue,loadPanel 显式调一次。

加载顺序已确认:两者都是 defer,entry_popup.js(253) 在前、
task_panel.js(268) 在后 ⇒ task_panel 先执行完并注册好
window.fbTaskPanelBindDue,之后 loadPanel 才在用户点击时调用。
不存在"调的时候还没定义"。

antd 样式确认:vendor 目录只有 reset.css(antd 5 是 CSS-in-JS 运行时注入),
不存在"缺样式导致看起来像原生下拉"的可能。
2026-11-05 17:59:17 +08:00

421 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
首页任务面板 —— 就地改状态 / 挪看板 / 加子任务 / 传附件 / 发评论(v78)
----------------------------------------------------------------------------
用户原话(2026-10-22):
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
这块面板由 `tasks/panel` 吐 HTML、`entry_popup.js` 塞进 `#fbEntryBody`。
本脚本只做一件事:**把面板里的每个动作发出去,再把服务端回来的新面板换上去**。
三条纪律(每条都对应一次踩过的坑):
① **一律事件委托挂 document**:面板 HTML 每次动作后整块换掉
(换完就是一批全新的节点),逐个 `addEventListener` 必然漏第二块。
② **动作之后重画整块面板**,不自己改 DOM:
勾一条子任务会改「2/3」这个计数、传一个附件会改附件张数与缩略图墙、
挪看板会改上面那排 chip 的选中态 —— 逐处同步必然漏一处(v55 手写过一次,
结局是"下拉改了、页头还写着旧状态")。面板很小,重画比同步便宜且不会自相矛盾。
③ **拿不到 JSON 就整页重载**:宁可刷一次,也不要在屏幕上留一个"看着成功了、
其实服务端没记上"的假状态。这与全站"没有 JS 也能用"的口径是同一条。
============================================================================ */
(function () {
'use strict';
function bodyEl() { return document.getElementById('fbEntryBody'); }
function panelEl() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-panel]') : null;
}
function tipEl() { return document.getElementById('fbEntryTip'); }
function setTip(msg, err) {
var t = tipEl();
if (!t) { return; }
t.textContent = msg || '';
t.className = 'cal-modal-tip' + (err ? ' err' : '');
}
/** CSRF:面板自己带一份(`data-tk-csrf`);没有就退回浮层遮罩里那份 */
function csrf() {
var p = panelEl();
var el = p ? p.querySelector('[data-tk-csrf]') : null;
if (!el) { el = document.querySelector('#fbEntryModal .fb-entry-csrf input[name="_csrf"]'); }
return el ? el.value : '';
}
function taskId() {
var p = panelEl();
return p ? (p.getAttribute('data-tk-panel') || '0') : '0';
}
/** 面板在 HTML 里声明自己的动作地址 —— 脚本里**不写死路由名**(改名不会漏改这一处) */
function urlOf(key) {
var p = panelEl();
return p ? (p.getAttribute('data-tk-url-' + key) || '') : '';
}
/** 用服务端回来的新面板替换旧的(成功失败都换:失败时服务端给的是**真实状态**) */
function paint(d, keepDraft) {
var b = bodyEl();
if (!b || !d || typeof d.html !== 'string' || d.html === '') { return false; }
/* ⚠️ 传附件时**保住**用户还没发出去的那段留言:
拖一张图就把写了一半的评论清空,是那种"不报错、但让人想骂人"的丢失。
其他动作(发评论/加子任务)不保 —— 那是他自己刚提交的内容,回填反而是错的。 */
var draft = '', draftTitle = '';
if (keepDraft) {
var ta = b.querySelector('textarea[name="content"]');
if (ta) { draft = ta.value; }
var ti = b.querySelector('.tk-inline-form input[name="title"]');
if (ti) { draftTitle = ti.value; }
}
b.innerHTML = d.html;
if (draft !== '') {
var ta2 = b.querySelector('textarea[name="content"]');
if (ta2) { ta2.value = draft; }
}
if (draftTitle !== '') {
var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
if (ti2) { ti2.value = draftTitle; }
}
/* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息,
包括…修改截止时间。」
⇒ 面板里现在有 AntD 的单点日期控件(`[data-dt-antd-single]`)。
这段 HTML 是 AJAX 换进来的,**不会自己跑初始化** ——
`bootAntd()` 只在页面加载时扫过一次 `[data-dt-antd-single]`,
那时面板还不存在 ⇒ 控件会是一片原生下拉(或空白)。
⇒ 每次重画面板都要补挂一次。
⚠️ `mountAntd*` 有 `data-antd-done` 幂等标记,重复调用不会挂两遍。
⚠️ 必须放在 `innerHTML` 之后 —— 要挂的节点得先真的在 DOM 里。 */
if (typeof window.fbBootAntd === 'function') {
try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ }
}
/* 重画之后节点是新的 ⇒ 监听要重新绑一遍(旧的已经随旧节点一起没了) */
bindDue();
return true;
}
/**
* 监听「截止时间」控件的变化,改完**立即保存**(#74)。
*
* 为什么不用 `data-tk-act="due"` 那种按钮:
* 面板里其它动作都是"点一下就生效",截止时间是**选完时间**就该生效,
* 再让用户点一次「保存」会让这一处与其它处不一致。
*
* 为什么监听**隐藏字段的 change** 而不是 AntD 的回调:
* · AntD 挂载时会把选中的值写回 `.dt-date` / `.dt-hour` / `.dt-minute`
* 这些原生字段,并 copy 一个 `change` 事件(见 app.js 的 writeDT);
* · 原生下拉那一档(AntD 没挂上时的兜底)也是同一批发 `change`;
* · ⇒ 监听一次就能覆盖**两种形态**,不用分别处理。
*
* ⚠️ `data-fb-due-bound` 幂等标记:`bindDue()` 在每次 paint 后都会跑,
* 没有它会给同一个节点挂上多个监听 ⇒ 改一次时间发好几条请求。
*/
function bindDue() {
var b = bodyEl();
if (!b) { return; }
var box = b.querySelector('.dt-input[data-dt]');
if (!box) { return; }
var cfg = {};
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { return; }
if (cfg.name !== 'due_at') { return; }
if (box.getAttribute('data-fb-due-bound') === '1') { return; }
box.setAttribute('data-fb-due-bound', '1');
/* 用 `change`(不是 `input`):AntD 选完日期/时间才写回,
中途打字不该触发几十次请求。 */
box.addEventListener('change', function () {
var d = box.querySelector('.dt-date');
var date = d ? String(d.value || '').trim() : '';
if (date === '') {
/* 清空 = 去掉截止时间(allowClear 会走到这里) */
act(urlOf('due'), { due_at: '' });
return;
}
var h = box.querySelector('.dt-hour');
var m = box.querySelector('.dt-minute');
var hh = h && String(h.value || '').trim() !== '' ? String(h.value) : '00';
var mm = m && String(m.value || '').trim() !== '' ? String(m.value) : '00';
act(urlOf('due'), { due_at: date + ' ' + hh + ':' + mm + ':00' });
});
}
/**
* 把面板里的最新状态**顺手**同步到首页那张卡上(v78)。
*
* 为什么要有这一步:面板是浮层,背后那张任务是"带着旧状态"站在那儿的。
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
* 不在这里猜(猜错比不猜更糟)。
*
* 🔴🔴 工单 #20(用户 2026-10-28,第二次提同一条需求):
* 「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」
* 第一版只改了行首那颗复选框(home.php 里的脚本),**漏了这里**——
* 从面板点「已完成」时,行留在原地只把徽标改成「已完成」。
* 同一个用户动作(都是"标成完成")两条路径两种结果,用户复现的就是这条。
*
* ⇒ 变更后是 done 时,也把这一行从首页卡片里摘掉。
* 真正干活的在 app.js 的 `window.fbHomeTaskDrop()`:它自己判断
* "行在不在 `[data-tk-home-host]` 里",**不在就完全不碰**
* (任务看板页的行也在 `[data-task-row]` 上,那里必须保留该行)。
*
* ⚠️ `d.status` 是**父任务**的状态(`task_panel_json()` 用 `panel_of` 找的父任务,
* 见 app/helpers.php:506)。面板里勾的是**子任务**时,请求带 `panel_of`,
* 回来的 status 仍是父任务的 ⇒ 这里不会因为"子任务做完了"就删掉父任务那一行。
*
* ⚠️ 删行前先把 `wasStatus` 取出来:下面几行会改写 `data-tk-status`,
* 改完再读就只能读到 done,概述卡不知道该减「在做」还是「待办」。
*/
function syncRow(d) {
if (!d || !d.status) { return; }
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
if (!row) { return; }
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
var chk0 = row.querySelector('.tk-check[data-tk-done]');
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
var badge = row.querySelector('.kb-st');
if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static';
if (d.word) { badge.textContent = d.word; }
}
if (chk0) {
chk0.setAttribute('data-tk-status', d.status);
chk0.classList.toggle('is-done', d.status === 'done');
chk0.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
}
/* 标成完成 ⇒ 从首页「近期任务」里摘掉(工单 #20)。
⚠️ 用服务端回的 `d.status`,不是"我点之前猜的 next"。 */
if (d.status === 'done' && typeof window.fbHomeTaskDrop === 'function') {
window.fbHomeTaskDrop(row, was);
}
}
/**
* 发一个动作。
* @param {string} url 动作地址(空 = 这条路由没登记,直接放弃)
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
*/
function send(url, fd, keepDraft) {
if (!url) { setTip('这一项暂时不能在首页改。', true); return; }
if (!window.fetch || !window.FormData) { window.location.reload(); return; }
fetch(url, {
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) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实
paint(d, keepDraft);
syncRow(d);
setTip(d.msg || '', !d.ok);
}).catch(function () { window.location.reload(); });
}
/** 把「id + 几个附加字段」包成一个动作请求 */
function act(url, extra) {
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', taskId());
fd.append('ajax_panel', '1');
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
send(url, fd);
}
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
面板里**只有需要打字或选文件的地方**才用真正的 <form>(见 _panel.php 头部第 ② 条),
它们各自带 `ajax_panel=1` —— 这里按这个字段认人,别的表单(页面上的)不碰。 */
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.closest || !f.closest('#fbEntryBody')) { return; }
if (!f.querySelector('input[name="ajax_panel"]')) { return; }
e.preventDefault();
send(f.getAttribute('action') || '', new FormData(f));
});
/* ---- ② 按钮动作(状态 / 看板 / 删评论 / 子任务复选框)------------------- */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('#fbEntryBody')) { return; }
/* 子任务复选框:走**状态**那条路由(与看板/详情页那颗复选框同一个入口)。
⚠️ 必须 stopPropagation:首页列表那套监听是按"加载时存在的元素"绑的,
但万一以后改成委托,这里不拦就会一次点击发两个请求。
⚠️ 勾的是**子任务**,所以 id 用复选框自己的 —— 不能套 `act()`(它默认带父任务 id)。
再补一个 `panel_of`:服务端据此重画**父任务**的面板(拿子任务 id 去画,
得到的是"一块只有它自己的面板",用户会觉得父任务不见了)。 */
var chk = t.closest('.tk-check[data-tk-done]');
if (chk) {
e.preventDefault();
e.stopPropagation();
var now = chk.getAttribute('data-tk-status') || 'todo';
var kidFd = new FormData();
kidFd.append('_csrf', csrf());
kidFd.append('id', chk.getAttribute('data-tk-done') || '0');
kidFd.append('status', now === 'done' ? 'todo' : 'done');
kidFd.append('ajax_panel', '1');
kidFd.append('panel_of', taskId());
send(urlOf('status'), kidFd);
return;
}
var btn = t.closest('[data-tk-act]');
if (!btn) { return; }
var what = btn.getAttribute('data-tk-act');
if (what === 'status') {
e.preventDefault();
if (btn.classList.contains('is-on')) { return; } // 已经是这个状态,别白跑一趟
act(urlOf('status'), { status: btn.getAttribute('data-status') || 'todo' });
return;
}
if (what === 'board') {
e.preventDefault();
if (btn.classList.contains('is-on')) { return; }
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
return;
}
if (what === 'cmt-del') {
e.preventDefault();
if (!window.confirm('删掉这条留言?')) { return; }
/* 删留言的契约是 `id`=留言 id、`task_id`=所属任务(既有路由就这么收的),
所以这里自己拼,不套 act()。 */
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', btn.getAttribute('data-cmt') || '0');
fd.append('task_id', taskId());
fd.append('ajax_panel', '1');
send(urlOf('cmt-del'), fd);
return;
}
});
/* ---- ③ 附件:点击 / 拖动 → **立刻**上传,不用再点一次"上传" -------------
为什么是"立刻":这条任务已经存在了,不像新建表单那样还要等整体提交
(`_stage_drop.php` 那套暂存令牌是给"表单还没提交"准备的,见 _panel.php 的说明)。 */
function pickInput() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-drop-input]') : null;
}
function dropBox() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-drop-box]') : null;
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (!t.closest('[data-tk-drop-box]')) { return; }
e.preventDefault();
var inp = pickInput();
if (inp) { inp.click(); }
});
/* 键盘也能用(框上有 tabindex="0" + role="button") */
document.addEventListener('keydown', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('[data-tk-drop-box]')) { return; }
if (e.key !== 'Enter' && e.key !== ' ') { return; }
e.preventDefault();
var inp = pickInput();
if (inp) { inp.click(); }
});
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
document.addEventListener('dragover', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('[data-tk-drop-box]')) { return; }
e.preventDefault();
t.closest('[data-tk-drop-box]').classList.add('is-over');
});
document.addEventListener('dragleave', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var box = t.closest('[data-tk-drop-box]');
if (box) { box.classList.remove('is-over'); }
});
document.addEventListener('drop', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var box = t.closest('[data-tk-drop-box]');
if (!box) { return; }
e.preventDefault();
box.classList.remove('is-over');
var dt = e.dataTransfer;
if (!dt || !dt.files || !dt.files.length) { return; }
upload(dt.files);
});
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-tk-drop-input')) { return; }
if (!t.files || !t.files.length) { return; }
upload(t.files);
t.value = ''; // 同一个文件再选一次也要能触发 change
});
/**
* 逐个上传(串行)。**全部传完才重画一次**面板 ——
* 每传一张就重画会把正在用的落点框换掉,用户拖第二张时框已经是个新节点。
*/
function upload(files) {
var url = urlOf('attach');
if (!url) { setTip('这一项暂时不能传附件。', true); return; }
var tip = bodyEl() ? bodyEl().querySelector('[data-tk-drop-tip]') : null;
var total = files.length, i = 0, last = null, bad = 0;
if (tip) { tip.hidden = false; tip.textContent = '正在上传 0/' + total + '…'; }
function step() {
if (i >= total) {
if (last) { paint(last, true); setTip(last.msg || '', !last.ok); }
if (tip) {
tip.hidden = false;
tip.textContent = bad > 0
? ('有 ' + bad + ' 个文件没传上去。')
: ('已传 ' + total + ' 个。');
}
if (!last && bad > 0) { setTip('附件没传上去,再试一次。', true); }
return;
}
var f = files[i++];
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', taskId());
fd.append('ajax_panel', '1');
fd.append('attach[]', f);
if (tip) { tip.textContent = '正在上传 ' + (i < total ? i + 1 : total) + '/' + total + '…'; }
fetch(url, {
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.html) { last = d; } else { bad++; }
step();
}).catch(function () { bad++; step(); });
}
step();
}
/**
* 对外暴露「给截止控件绑 change 监听」(#74)。
*
* 为什么需要:首次打开面板走的是 `entry_popup.js` 的 `loadPanel()`,
* 那条路只 innerHTML、不经过本文件的 `paint()` ⇒ `bindDue()` 不会自动跑
* ⇒ 用户选完截止时间不会保存(只有做过一次别的动作、触发过一次重画才会生效)。
* `loadPanel()` 在插入 HTML 后显式调这个。
*
* ⚠️ 内部有 `data-fb-due-bound` 幂等标记,重复调用不会重复绑。
*/
window.fbTaskPanelBindDue = bindDue;
})();