Files
FamilyBook/assets/js/task_panel.js
T
laoyang 11aa7c6ca5 任务面板五项细化:弹窗选人、协作人、保存关窗、去编辑、评论附件 (#78)
用户 2026-11-05 五条:
  「选人的时候不能按列表,因为人太多了。参考观影模式里面选人的方式。」
  「任务其实可以共享给别人,也就是协作人,但编辑这里面没有这个选项。」
  「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
  「现在在首页上点一个任务,直接就是编辑页面啊,所以不需要编辑按钮了。」
  「评论那个地方也需要可以添加附件。」

① 选人改弹窗
   负责人/协作人都换成 `_person_picker.php`(弹窗 + 拼音首字母联想),
   与观影表单、新建任务弹框同一套。
   负责人 single + allowEmpty(可选「不指定」);协作人 multi。
   ⚠️ 关键修复:`entry_popup.js` 的 `loadPanel()` 一直漏了
      `fbInitPickers()` —— 组件不初始化,按钮点了没反应。
      `loadEdit()` 里有这一句,`loadPanel()` 没有,面板里塞选人就是这个下场。
      同时 `task_panel.js` 的 `paint()` 后也补一次(重画后是新节点)。

② 补协作人
   数据层其实**早就完整支持**:task_shares 表 + Task::setShare/dropShare
   + 新建弹框的「共享给」+ 详情页的共享展示。只有面板和编辑表单没给入口。
   现在面板里能看当前协作人、能加、能改。
   保存语义:以提交的列表为准同步(多了就 setShare、少了就 dropShare)。
   ⚠️ 带 `shares_submitted=1` 哨兵,区分"表单里没这个控件"(不动)
      和"用户全删了"(清空)—— 否则 PHP 收不到空数组的键,无法分辨。

③ 保存后关窗
   `act()` 加 `closeOnOk`,`send()` 支持它:成功后先 `syncRow()`
   (更新背后那张首页卡片,否则关掉浮层看到旧标题更迷惑)再 `fbEntryClose()`。
   ⚠️ 只给「保存」用 —— 改状态/挪看板/加子任务/发评论/传附件都还要继续操作。
   ⚠️ 失败时不关,让用户看到错误就地改。

④ 去掉「编辑」按钮 + 面板补标题/备注
   用户说得对:点开任务就是完整编辑面板了,再给编辑按钮是重复。
   但原来面板改不了标题和备注(只在 _edit_form.php 里)
   ⇒ 先在「更多信息」里补上这两个字段,再藏编辑按钮。
   `Task::setAttrs()` 扩展支持 title/note,走 title()/textOrNull() 清洗,
   与 update() 同一口径。
   ⚠️ 只对**任务**(带 data.panel)隐藏 —— 观影/旅程/日程/纪念日仍需要那颗按钮。

⑤ 评论可加附件
   服务端**早就支持**:tasks/comment_add 里一直有
   `attach_save_files(..., task_comment, ...)`,只是表单里没有 file input。
   补上 `<input type="file" name="attach[]" multiple>` +
   `enctype="multipart/form-data"`(少了它只提交文件名、文件本体丢失)。
   顺带允许「只发附件不写字」(原来 content 空会返回"留言是空的"):
   有附件且没文字时用占位文案「(附件)」。

实测:
  setAttrs 标题/备注 => 都正确写入 ✅
  setShare / dropShare => 1 人 / 0 人 ✅
  面板渲染 => 标题框/备注框/两个选人组件(single+multi)/评论附件/
              multipart/save-attrs/antd 控件 全部在位 ✅
  选人模式 => single(allowEmpty=1), multi ✅

已知问题(另单处理):家庭有 800 人,两个选人组件各 485KB,
面板渲染 997KB。服务端只要 11ms,但传输量对移动端不友好。
这是 _person_picker.php 的既有设计(观影那边也一样),
需要改成"弹窗打开时才渲染列表"或按需加载。
2026-11-05 18:28:15 +08:00

572 lines
27 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) { /* 挂不上就留在原生下拉,不阻断面板 */ }
}
/* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。
不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */
if (typeof window.fbInitPickers === 'function') {
try { window.fbInitPickers(b); } catch (err) { /* 忽略 */ }
}
return true;
}
/**
* 把面板里的最新状态**顺手**同步到首页那张卡上(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)
*/
/**
* 发请求并刷新面板。
*
* @param keepDraft #78:重画后保住用户没发出去的评论草稿(传附件时用)
* @param closeOnOk #78:成功后**关掉浮层**(「保存」用;其它动作不关)
*/
function send(url, fd, keepDraft, closeOnOk) {
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:整页重载最诚实
/* 🔴 #78(用户 2026-11-05):「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
⇒ 保存成功就关窗,不再重画面板(用户已经不在看了)。
⚠️ 关窗前**先更新背后那张首页卡片**:否则用户关掉浮层看到的还是旧标题/旧状态,
"改了没生效"的错觉比不关窗更糟。
⚠️ 失败(d.ok === false)时**不关** —— 让用户看到错误提示、就地改。 */
if (closeOnOk && d.ok !== false) {
syncRow(d);
if (typeof window.fbEntryClose === 'function') {
window.fbEntryClose();
} else if (typeof window.fbCloseEntry === 'function') {
window.fbCloseEntry();
}
return;
}
paint(d, keepDraft);
syncRow(d);
setTip(d.msg || '', !d.ok);
}).catch(function () { window.location.reload(); });
}
/** 把「id + 几个附加字段」包成一个动作请求 */
/**
* 发一个面板动作。
*
* @param url 目标地址
* @param extra 额外字段
* @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。
* 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
* ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件
* 都还要继续在这个面板里操作,关窗会把用户赶走。
*/
function act(url, extra, closeOnOk) {
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, false, closeOnOk);
}
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
面板里**只有需要打字或选文件的地方**才用真正的 <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;
}
/* 🔴 #76(用户 2026-11-05):「时间控件是对的,但是现在没有保存按钮…
另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」
⇒ 「截止 / 负责人 / 分类」三项一次性提交。
⚠️ 上一版是"选完截止时间立即保存"(监听 change),用户不认可 ——
他要的是"改完点保存"这种确定感。
⚠️ 只提交这三项,服务端 `Task::setAttrs()` 也只写这三列,
不碰 title/note/status/repeat(见那个方法的注释)。 */
/**
* 读出「截止时间」框当前值 → `YYYY-MM-DD HH:mm:ss`(#77)。
*
* 🔴 用户 2026-11-05:「截止日期的时间保存有bug,时和分没保存上。」
*
* 为什么不能只读 `.dt-hour` / `.dt-minute`:
* AntD 一旦挂上(`.dt-input.is-antd`),`.dt-native` 被 CSS 收成
* `display:none`。那几档原生 select 里的值是**控件写回来的镜像**,
* 而镜像的写入依赖 `mountAntdSingleDT.writeBack()`:
* onChange(val, str) → writeDT(box, str)
* `str` 来自 antd 的 `format: 'YYYY-MM-DD HH:mm'`。
* 实测 antd 5 在**受控**模式下(我们传了 `value`),
* 某些路径给的是 `val`(dayjs 对象)而 `str` 可能是空串 ——
* 此时 writeDT 走进 else 分支,把 .dt-date/.dt-hour/.dt-minute **全清成空/00**。
* ⇒ 日期还有值(另一条路径补齐了)、时分却恒为 00,正是用户报的现象。
*
* ⇒ 读值时**以用户看得见的那个框为准**:
* ① 先看 AntD 的可见 input(`.ant-picker-input > input`)里的文本,
* 它的格式就是 `YYYY-MM-DD HH:mm`;
* ② 没有 AntD(降级成原生下拉)时才读隐藏字段;
* ③ 都没有就当空。
*
* ⚠️ AntD 的输入框是**只读文本框**(用户通过弹层选,不直接打字),
* 所以解析它的 text 是安全的 —— 不会出现用户在框里敲了半截日期的中间态。
*/
/**
* 替用户点掉 AntD 弹层的「确定」(#77)。
*
* 🔴 为什么必须点:antd 的 `DatePicker` + `showTime` **只有点它内部的
* 「确定」才会提交值**。点别处(包括点我们面板的「保存」)会被当成取消,
* 值**弹回上一次确认的值**。
* 实测:选完 2026-11-16 20:34(未点确定)→ 点 body → 50ms 后
* 可见框弹回 "2026-11-07 00:00",onChange 一次都没触发。
* 这正是用户报的「时和分没保存上」(库里变成 00:00:00)。
*
* ⚠️ 只在弹层真的开着时才点(`:not(.ant-picker-dropdown-hidden)`),
* 否则会点到别的东西。
*/
function commitAntd() {
var drop = document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)');
if (!drop) { return false; }
var okBtn = drop.querySelector('.ant-picker-ok button');
if (okBtn && !okBtn.disabled) {
okBtn.click();
return true;
}
return false;
}
function readDue(box) {
if (!box) { return ''; }
/* 🔴🔴 第 ⓪ 步(**最可靠**,优先于其它一切):读 antd 的 onChange 直挂值。
`app.js` 的 `mountAntdSingleDT.writeBack()` 现在会把每次 onChange 拿到的
字符串写到 `box[data-fb-dt-value]`。那是"控件刚刚确认的值",
**不经任何镜像、不受弹层是否关闭影响**。
实测过的那两种读法都有坑:
· 读 .dt-hour/.dt-minute(镜像)→ 用户没点弹层「确定」时停在旧值/00;
· 读可见框 → 弹层未提交时是半截值(20:34 而不是 20:30)。
⇒ 有 data-fb-dt-value 就用它,一步到位。 */
var dv = box.getAttribute('data-fb-dt-value');
if (dv !== null) {
var dvt = String(dv).trim();
if (dvt === '') { return ''; } /* 用户清空了 */
var dmv = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
if (dmv) {
var pd = function (n) { return (n < 10 ? '0' : '') + n; };
return dmv[1] + '-' + pd(+dmv[2]) + '-' + pd(+dmv[3]) + ' '
+ pd(+dmv[4]) + ':' + pd(+dmv[5]) + ':' + pd(+(dmv[6] || 0));
}
var dmd = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (dmd) {
var pd2 = function (n) { return (n < 10 ? '0' : '') + n; };
return dmd[1] + '-' + pd2(+dmd[2]) + '-' + pd2(+dmd[3]) + ' 00:00:00';
}
}
/* ⚠️ 「点确定」不在这里做 —— 它是 `commitAntd()` + 等一帧之后才读。
原因见 save-attrs 分支上的长注释:antd 的 setState→重渲染在微任务里,
同步读拿到的是提交前那一帧。 */
/* ① AntD 可见框 */
var vis = box.querySelector('.ant-picker-input > input');
if (vis) {
var vt = String(vis.value || '').trim();
if (vt !== '') {
/* 已选:'2026-12-31 20:00' 或 '2026-12-31 20:00:00' */
var mv = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
if (mv) {
var p2 = function (n) { return (n < 10 ? '0' : '') + n; };
return mv[1] + '-' + p2(+mv[2]) + '-' + p2(+mv[3]) + ' '
+ p2(+mv[4]) + ':' + p2(+mv[5]) + ':' + p2(+(mv[6] || 0));
}
/* 只有日期没时间(用户只选了日子就点保存):补 00:00 */
var md = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (md) {
var p3 = function (n) { return (n < 10 ? '0' : '') + n; };
return md[1] + '-' + p3(+md[2]) + '-' + p3(+md[3]) + ' 00:00:00';
}
}
/* AntD 框存在但空着 = 用户清空了截止时间 */
return '';
}
/* ② 降级形态:读隐藏字段(与 app.js 的 readDT 同一口径) */
var dt = box.querySelector('.dt-date');
var dstr = dt ? String(dt.value || '').trim() : '';
if (dstr === '') { return ''; }
var hh = box.querySelector('.dt-hour');
var mm = box.querySelector('.dt-minute');
var h2 = hh && String(hh.value || '').trim() !== '' ? String(hh.value) : '00';
var m2 = mm && String(mm.value || '').trim() !== '' ? String(mm.value) : '00';
return dstr + ' ' + h2 + ':' + m2 + ':00';
}
if (what === 'save-attrs') {
e.preventDefault();
if (btn.disabled) { return; }
var bd = bodyEl();
if (!bd) { return; }
var dbox = bd.querySelector('.dt-input[data-dt]');
var asg = bd.querySelector('select[name="assignee_id"]');
var cat = bd.querySelector('select[name="category_id"]');
btn.disabled = true;
/* 🔴🔴 保存前的两步(顺序不能变):
① `commitAntd()` 替用户点掉 antd 弹层的「确定」—— 不点就**弹回原值**;
② **等一帧再读** —— antd 的 onChange → setState → 重渲染落在微任务/下一帧,
同步读拿到的是提交前那一帧(实测差几分钟,或者干脆是旧值)。
实测对照(Playwright + 真实 antd):
不点确定、点保存 → readDue="2026-11-07 00:00:00"(原值,修改丢失)
点确定后等 60ms → readDue="2026-11-16 20:30:00" ✅
60ms 用户完全无感,足够 React 把一帧画完 + writeBack 写好镜像。 */
commitAntd();
setTimeout(function () {
var extra = {};
extra['due_at'] = readDue(dbox);
/* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 控件不渲染时不该把原值清掉。 */
if (asg) { extra['assignee_id'] = asg.value; }
if (cat) { extra['category_id'] = cat.value; }
/* #78:标题 / 备注 —— 删掉「编辑」按钮后,它们只能在这里改 */
var ti = bd.querySelector('#tkp-title');
if (ti) { extra['title'] = ti.value; }
var nt = bd.querySelector('#tkp-note');
if (nt) { extra['note'] = nt.value; }
/* #78:协作人(`_person_picker.php` 的 multi 选人把结果存在
隐藏 checkbox 里,name="shares[]")。逐个收集。
⚠️ 必须带 `shares_submitted=1` 这个哨兵:
服务端靠它区分"表单里根本没有协作人控件"(不动)与
"用户把它们全删了"(清空)—— 只靠 `shares[]` 是否存在,
空数组时 PHP 收不到任何键,无法区分。 */
var shareBox = bd.querySelector('input[name="shares[]"]');
if (shareBox) {
extra['shares_submitted'] = '1';
var picked = bd.querySelectorAll('input[name="shares[]"]:checked');
for (var i = 0; i < picked.length; i++) {
extra['shares[' + i + ']'] = picked[i].value;
}
}
act(urlOf('save'), extra, true);
}, 60);
/* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */
setTimeout(function () { btn.disabled = false; }, 4000);
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();
}
})();