Files
FamilyBook/assets/js/task_panel.js
T
laoyang 33940fa730 评论附件拖放区放大:小胶囊 → 整块虚线大框 (#81)
用户 2026-11-05:「评论里面拖文件上传附件,那个地方太小了。」

原来:`padding:4px 10px` 的小胶囊(约 26px 高)+ 一行小字提示,
拖动落点只有那一条窄带 —— 触屏不好瞄,桌面也容易拖偏。

改成与「任务附件」同一套 `.upbox`(那个是站点级上传组件的标准外观):
  · 高度 89px(触屏建议下限 44px,绰绰有余)
  · 宽度占满整行(桌面 840px / 手机 330px)
  · 虚线大框 + 加号图标 + 说明文字
  · 副标题写清「点『发表』时随这条评论一起发出」——
    与上面那块「拖上去立刻上传」语义不同,不写清楚用户会以为已经传了
  · 比「任务附件」矮一点(96 → 72 min-height,实测 89px):
    评论框已经在下面,96px 会把讨论区推太长;89px 仍然很好点

顺带改的:
  · 已选文件从"逗号拼一行"改成"一行一个,带 📎",长名截断
  · 渲染用 createElement + textContent,**不拼 innerHTML**
    (文件名是用户可控的,拼 innerHTML 就是 XSS)
  · 列表为空时 hidden,不挂空灰条
  · 加键盘支持:`.upbox` 带 role=button + tabindex,Enter/Space 能打开选择框

实测(Playwright,两种视口):
  桌面 900px:840 × 89px,边框 1px dashed,触屏可点性 ✅
  手机 390px:330 × 89px,触屏可点性 ✅
  文件列表:2 项,高 56px,正常显示
2026-11-05 18:57:56 +08:00

740 lines
34 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; }
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
根因:这个函数原来**只管状态徽标和复选框**,不碰标题。
而 #78 之后面板里能改标题了 ⇒ 改完标题点保存,卡片上还是旧标题。
⇒ 补上标题同步:`.dash-what` 的文本 + 它的 `title` 属性
(`title` 是鼠标悬停提示,不同步会出现"悬停是旧标题"的怪事)。
⚠️ 只在服务端真的回了 `title` 时才改 —— `task_panel_json()` 目前
不带这个字段(见 app/helpers.php),所以下面还有一道兜底:
从当前面板的输入框里读。两条路任一命中即可。 */
var newTitle = (typeof d.title === 'string' && d.title !== '') ? d.title : '';
if (newTitle === '') {
var ti = bodyEl() ? bodyEl().querySelector('#tkp-title') : null;
if (ti) { newTitle = String(ti.value || '').trim(); }
}
if (newTitle !== '') {
var what = row.querySelector('.dash-what');
if (what) {
what.textContent = newTitle;
what.setAttribute('title', newTitle);
}
/* 浮层下次打开时读的是 `data-fb-entry` 里的 title —— 一并改掉,
否则"卡片上是新标题、再点开浮层显示旧标题"。 */
if (what && what.getAttribute('data-fb-entry')) {
try {
var ent = JSON.parse(what.getAttribute('data-fb-entry'));
ent.title = newTitle;
what.setAttribute('data-fb-entry', JSON.stringify(ent));
} catch (e) { /* JSON 坏了就跳过,不阻断 */ }
}
}
/* 🔴 必须在改写 `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(); }
});
/* ============================================================
#79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
与上面「任务附件」的拖拽是**两条独立的路**,别混:
· `[data-tk-drop-box]` = 点了/拖了**立刻上传**(任务已经存在,传上去就生效);
· `[data-cmt-drop]` = 把文件挂到评论表单上,**点「发表」才一起提交**
(评论是先写文字再发,附件跟着这条评论走)。
🔴 核心技术点:`<input type=file>.files` 是**只读**的,
不能 `input.files = event.dataTransfer.files`(直接赋值在某些浏览器
会静默失败)。必须用 `DataTransfer` 重建一个 FileList 赋回去:
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files; ← 这个赋值是允许的
而且**必须真的赋给 input**,否则只是"看起来加上了"、
表单提交时浏览器一个文件都不带。
⚠️ 支持**多次拖入累加**:用户可能先拖一张再拖一张。
每次取 `input.files` 现有内容 + 新拖进来的,合并后写回。
⚠️ 同一文件重复拖入要跳过(按 name+size+lastModified 判重),
否则会出现"我明明只拖了一次,传上去两张一样的"。
============================================================ */
function cmtDropZone(e) {
var t = e.target;
if (!t || !t.closest) { return null; }
return t.closest('[data-cmt-drop]');
}
function cmtFileInput(zone) {
return zone ? zone.querySelector('[data-cmt-file-input]') : null;
}
/** 把 files 合并进 input(累加 + 去重),并更新旁边那行文件名提示 */
function cmtAddFiles(zone, files) {
var input = cmtFileInput(zone);
if (!input || !files || !files.length) { return; }
var dt;
try { dt = new DataTransfer(); } catch (e) { return; } // 老浏览器:放弃,用户仍可点选
/* 先把已有的放进去(累加语义) */
var seen = {};
var add = function (f) {
if (!f) { return; }
var key = f.name + '|' + f.size + '|' + (f.lastModified || 0);
if (seen[key]) { return; }
seen[key] = 1;
dt.items.add(f);
};
if (input.files) {
for (var i = 0; i < input.files.length; i++) { add(input.files[i]); }
}
for (var j = 0; j < files.length; j++) { add(files[j]); }
try { input.files = dt.files; } catch (e) { /* 个别内核只读:下面提示照旧 */ }
/* 更新已选文件列表(用户要能确认"确实加上了哪几个")。
#81:从"一行逗号拼接"改成"一行一个" —— 拖 5 个文件时
逗号拼起来读不清,也看不出到底几个。 */
cmtRenderNames(zone, input);
}
/**
* 把 `input.files` 渲染成一行一个的列表(#81)。
*
* ⚠️ 用 `textContent` 逐个 append,**不要** `innerHTML +=` 拼字符串 ——
* 文件名是用户可控的(`<img onerror=...>.jpg` 这种),拼进 innerHTML
* 就是 XSS。用 `document.createElement('span')` + `textContent` 才安全。
* ⚠️ 列表为空时 `hidden`,非空才显示 —— 否则评论区永远挂着一块空灰条。
*/
function cmtRenderNames(zone, input) {
var box = zone.querySelector('[data-cmt-file-name]');
if (!box) { return; }
while (box.firstChild) { box.removeChild(box.firstChild); }
var n = input && input.files ? input.files.length : 0;
if (!n) {
box.hidden = true;
return;
}
for (var i = 0; i < n; i++) {
var sp = document.createElement('span');
sp.textContent = input.files[i].name;
box.appendChild(sp);
}
box.hidden = false;
}
/* dragover:必须 preventDefault,否则浏览器把文件当"打开"处理(页面跳走) */
document.addEventListener('dragover', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault();
zone.classList.add('is-over');
});
document.addEventListener('dragenter', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault();
});
document.addEventListener('dragleave', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
/* 只有真的离开这片区域才去掉高亮(进出子元素也会触发 dragleave) */
if (!zone.contains(e.relatedTarget)) {
zone.classList.remove('is-over');
}
});
document.addEventListener('drop', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault(); // 不拦的话浏览器会直接打开这个文件
zone.classList.remove('is-over');
var files = e.dataTransfer && e.dataTransfer.files;
cmtAddFiles(zone, files);
});
/* #81:新的上传框带 `role="button" tabindex="0"`(`.upbox` 的既有约定),
键盘 Enter/Space 也要能打开文件选择 —— 只做鼠标的话键盘用户点不动。 */
document.addEventListener('keydown', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var zone = t.closest('[data-cmt-drop]');
if (!zone) { return; }
if (e.key !== 'Enter' && e.key !== ' ') { return; }
e.preventDefault();
var inp = zone.querySelector('[data-cmt-file-input]');
if (inp) { inp.click(); }
});
/* 点选也要更新列表(原生 onchange)—— 与拖拽共用同一个渲染函数 */
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-cmt-file-input')) { return; }
var zone = t.closest ? t.closest('[data-cmt-drop]') : null;
if (!zone) { return; }
cmtRenderNames(zone, t);
});
/* 拖到框上要给 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();
}
})();