From aac7dd5974c59768f070aba17657d43028e4431e Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 5 Nov 2026 18:19:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=88=AA=E6=AD=A2=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=97=B6/=E5=88=86=E4=BF=9D=E5=AD=98=E4=B8=8D=E4=B8=8A?= =?UTF-8?q?=EF=BC=9A=E4=BF=9D=E5=AD=98=E5=89=8D=E6=9B=BF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=82=B9=20antd=20=E7=9A=84=E3=80=8C=E7=A1=AE=E5=AE=9A?= =?UTF-8?q?=E3=80=8D(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户给的方法很对:「看一下数据库里有没有记时和分。如果没有记, 就说明是修改它的时候出了问题。」查库结果: tasks #15 [台式机采购] due_at = 2026-11-07 00:00:00 updated = 2026-11-05 18:04:05 ← 正是用户操作的时间点 ⇒ 时分在**写入时**就丢了,不是显示问题。 用 Playwright + 站点真实 antd 5 逐帧实测,找到决定性证据: 选完日期/时间(不点弹层里的「确定」): vis="2026-11-16 20:34" onChange 次数 = 0 点面板的「保存」(这一下同时把弹层关掉): 50ms 后 readDue = "2026-11-07 00:00:00" ← **弹回原值!** onChange 次数仍然是 0 根因:antd 的 DatePicker(showTime) **只有点它内部的「确定」才提交**。 点别处(包括点我们的保存按钮)会被当成取消,值弹回上一次确认的值。 「台式机采购」原本就是 2026-11-07 00:00:00,用户改完时间直接点保存, antd 把修改丢了,于是存进去的还是原来的 00:00:00。 修法(三步,缺一不可): ① app.js 的 writeBack():把 onChange 拿到的字符串写到 box[data-fb-dt-value]。这是"控件刚确认的值",不经镜像、不受弹层影响。 ② task_panel.js 新增 commitAntd():保存前主动点掉弹层「确定」。 只在弹层真开着时点(:not(.ant-picker-dropdown-hidden))。 ③ 读值改为**异步**:commitAntd() 之后等 60ms 再 readDue()。 antd 的 onChange → setState → 重渲染落在微任务/下一帧, 同步读拿到的是提交前那一帧(实测差几分钟,或干脆是旧值)。 readDue 的优先级也理顺了: ① data-fb-dt-value(onChange 直挂,最可靠) ② antd 可见框(降级兜底) ③ 原生隐藏字段(antd 没挂上时) ④ 都没有 → 空(清掉截止时间) 实测(复刻完整流程:选日期+20时+30分,不点确定,直接点保存): 保存读到 = "2026-11-16 20:30:00" ✅ onChange 次数 = 1 dataset="2026-11-16 20:30" h=20 m=30 vis="2026-11-16 20:30" 服务端侧确认无需改动:Task::dtOrNull() 对 YYYY-MM-DD HH:mm[:ss] / YYYY-MM-DD 都能正确补全, 只有带 T 分隔的会返回 null(那是整条清空,不是时分丢失)。 --- assets/js/app.js | 13 +++++++ assets/js/task_panel.js | 78 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 58163d7..a842557 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2297,6 +2297,19 @@ writeDT(box, s); // 写回隐藏四字段 if (box.__fbSyncParts) { box.__fbSyncParts(); } box.classList.toggle('has-value', !!s); + /* 🔴 #77(用户 2026-11-05):「截止日期的时间保存有bug,时和分没保存上。」 + 真根因:面板保存时读 `.dt-hour` / `.dt-minute` —— 那是**镜像**, + 依赖本函数的 writeDT 写回。而实测(Playwright + 真实 antd)显示 + 用户在弹层里选完、**不点弹层里的「确定」** 就直接点面板的保存时, + 镜像还停在上一轮的值(甚至 00:00),于是时分就丢了。 + + ⇒ 把 onChange 拿到的字符串**直接挂到 box 上**。这是"控件刚刚确认的值", + 不经任何镜像、不受弹层是否关闭影响。面板保存时优先读它。 + + ⚠️ 用 `data-fb-dt-value` 而不是自定义 JS 属性:dataset 会同步到 DOM 属性, + 新节点/重渲染后也能用 `getAttribute` 拿到(虽然本控件是复用同一节点)。 + ⚠️ 空串也要写(= 用户清空了截止时间),所以不能 `if (s !== '')`。 */ + box.setAttribute('data-fb-dt-value', s); } var v0 = cur(); /* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。 diff --git a/assets/js/task_panel.js b/assets/js/task_panel.js index 187df02..7f2133c 100644 --- a/assets/js/task_panel.js +++ b/assets/js/task_panel.js @@ -268,8 +268,59 @@ * ⚠️ 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) { @@ -309,18 +360,29 @@ var bd = bodyEl(); if (!bd) { return; } var dbox = bd.querySelector('.dt-input[data-dt]'); - var dueVal = readDue(dbox); var asg = bd.querySelector('select[name="assignee_id"]'); var cat = bd.querySelector('select[name="category_id"]'); - var extra = {}; - /* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 没有成员时负责人下拉不渲染, - 那时不该把它当成 0 去清掉原值。 */ - extra['due_at'] = dueVal; - if (asg) { extra['assignee_id'] = asg.value; } - if (cat) { extra['category_id'] = cat.value; } btn.disabled = true; - act(urlOf('save'), extra); + /* 🔴🔴 保存前的两步(顺序不能变): + ① `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 里才提交该项 —— 没有成员时负责人下拉不渲染, + 那时不该把它当成 0 去清掉原值。 */ + if (asg) { extra['assignee_id'] = asg.value; } + if (cat) { extra['category_id'] = cat.value; } + act(urlOf('save'), extra); + }, 60); /* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */ setTimeout(function () { btn.disabled = false; }, 4000); return;