2026-10-22 12:40:14 +08:00
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
首页任务面板 —— 就地改状态 / 挪看板 / 加子任务 / 传附件 / 发评论(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; }
|
|
|
|
|
|
}
|
2026-11-05 17:44:11 +08:00
|
|
|
|
/* 🔴 #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) { /* 挂不上就留在原生下拉,不阻断面板 */ }
|
|
|
|
|
|
}
|
2026-11-05 18:28:15 +08:00
|
|
|
|
/* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。
|
|
|
|
|
|
不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */
|
|
|
|
|
|
if (typeof window.fbInitPickers === 'function') {
|
|
|
|
|
|
try { window.fbInitPickers(b); } catch (err) { /* 忽略 */ }
|
|
|
|
|
|
}
|
2026-10-22 12:40:14 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把面板里的最新状态**顺手**同步到首页那张卡上(v78)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要有这一步:面板是浮层,背后那张任务是"带着旧状态"站在那儿的。
|
|
|
|
|
|
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
|
|
|
|
|
|
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
|
|
|
|
|
|
* 不在这里猜(猜错比不猜更糟)。
|
2026-10-28 18:29:59 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 工单 #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,概述卡不知道该减「在做」还是「待办」。
|
2026-10-22 12:40:14 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function syncRow(d) {
|
|
|
|
|
|
if (!d || !d.status) { return; }
|
|
|
|
|
|
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
|
|
|
|
|
if (!row) { return; }
|
2026-10-28 18:29:59 +08:00
|
|
|
|
|
2026-11-05 18:32:40 +08:00
|
|
|
|
/* 🔴 #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 坏了就跳过,不阻断 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 18:29:59 +08:00
|
|
|
|
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
|
|
|
|
|
|
var chk0 = row.querySelector('.tk-check[data-tk-done]');
|
|
|
|
|
|
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
|
|
|
|
|
|
|
2026-10-22 12:40:14 +08:00
|
|
|
|
var badge = row.querySelector('.kb-st');
|
|
|
|
|
|
if (badge) {
|
|
|
|
|
|
badge.className = 'kb-st is-' + d.status + ' is-static';
|
|
|
|
|
|
if (d.word) { badge.textContent = d.word; }
|
|
|
|
|
|
}
|
2026-10-28 18:29:59 +08:00
|
|
|
|
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);
|
2026-10-22 12:40:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发一个动作。
|
|
|
|
|
|
* @param {string} url 动作地址(空 = 这条路由没登记,直接放弃)
|
|
|
|
|
|
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
|
|
|
|
|
|
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
|
|
|
|
|
|
*/
|
2026-11-05 18:28:15 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 发请求并刷新面板。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param keepDraft #78:重画后保住用户没发出去的评论草稿(传附件时用)
|
|
|
|
|
|
* @param closeOnOk #78:成功后**关掉浮层**(「保存」用;其它动作不关)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function send(url, fd, keepDraft, closeOnOk) {
|
2026-10-22 12:40:14 +08:00
|
|
|
|
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:整页重载最诚实
|
2026-11-05 18:28:15 +08:00
|
|
|
|
/* 🔴 #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;
|
|
|
|
|
|
}
|
2026-10-22 12:40:14 +08:00
|
|
|
|
paint(d, keepDraft);
|
|
|
|
|
|
syncRow(d);
|
|
|
|
|
|
setTip(d.msg || '', !d.ok);
|
|
|
|
|
|
}).catch(function () { window.location.reload(); });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把「id + 几个附加字段」包成一个动作请求 */
|
2026-11-05 18:28:15 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 发一个面板动作。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param url 目标地址
|
|
|
|
|
|
* @param extra 额外字段
|
|
|
|
|
|
* @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。
|
|
|
|
|
|
* 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
|
|
|
|
|
|
* ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件
|
|
|
|
|
|
* 都还要继续在这个面板里操作,关窗会把用户赶走。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function act(url, extra, closeOnOk) {
|
2026-10-22 12:40:14 +08:00
|
|
|
|
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]); });
|
2026-11-05 18:28:15 +08:00
|
|
|
|
send(url, fd, false, closeOnOk);
|
2026-10-22 12:40:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
|
|
|
|
|
|
面板里**只有需要打字或选文件的地方**才用真正的 <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;
|
|
|
|
|
|
}
|
2026-11-05 18:07:17 +08:00
|
|
|
|
/* 🔴 #76(用户 2026-11-05):「时间控件是对的,但是现在没有保存按钮…
|
|
|
|
|
|
另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」
|
|
|
|
|
|
|
|
|
|
|
|
⇒ 「截止 / 负责人 / 分类」三项一次性提交。
|
|
|
|
|
|
⚠️ 上一版是"选完截止时间立即保存"(监听 change),用户不认可 ——
|
|
|
|
|
|
他要的是"改完点保存"这种确定感。
|
|
|
|
|
|
⚠️ 只提交这三项,服务端 `Task::setAttrs()` 也只写这三列,
|
|
|
|
|
|
不碰 title/note/status/repeat(见那个方法的注释)。 */
|
2026-11-05 18:12:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 读出「截止时间」框当前值 → `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 是安全的 —— 不会出现用户在框里敲了半截日期的中间态。
|
|
|
|
|
|
*/
|
2026-11-05 18:19:46 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 替用户点掉 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 18:12:44 +08:00
|
|
|
|
function readDue(box) {
|
|
|
|
|
|
if (!box) { return ''; }
|
2026-11-05 18:19:46 +08:00
|
|
|
|
/* 🔴🔴 第 ⓪ 步(**最可靠**,优先于其它一切):读 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→重渲染在微任务里,
|
|
|
|
|
|
同步读拿到的是提交前那一帧。 */
|
2026-11-05 18:12:44 +08:00
|
|
|
|
/* ① 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 18:07:17 +08: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;
|
2026-11-05 18:19:46 +08:00
|
|
|
|
/* 🔴🔴 保存前的两步(顺序不能变):
|
|
|
|
|
|
① `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);
|
2026-11-05 18:28:15 +08:00
|
|
|
|
/* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 控件不渲染时不该把原值清掉。 */
|
2026-11-05 18:19:46 +08:00
|
|
|
|
if (asg) { extra['assignee_id'] = asg.value; }
|
|
|
|
|
|
if (cat) { extra['category_id'] = cat.value; }
|
2026-11-05 18:28:15 +08:00
|
|
|
|
|
|
|
|
|
|
/* #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);
|
2026-11-05 18:19:46 +08:00
|
|
|
|
}, 60);
|
2026-11-05 18:07:17 +08:00
|
|
|
|
/* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */
|
|
|
|
|
|
setTimeout(function () { btn.disabled = false; }, 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 12:40:14 +08:00
|
|
|
|
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(); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-05 18:32:40 +08:00
|
|
|
|
/* ============================================================
|
|
|
|
|
|
#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) { /* 个别内核只读:下面提示照旧 */ }
|
|
|
|
|
|
|
2026-11-05 18:57:56 +08:00
|
|
|
|
/* 更新已选文件列表(用户要能确认"确实加上了哪几个")。
|
|
|
|
|
|
#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;
|
2026-11-05 18:32:40 +08:00
|
|
|
|
}
|
2026-11-05 18:57:56 +08:00
|
|
|
|
for (var i = 0; i < n; i++) {
|
|
|
|
|
|
var sp = document.createElement('span');
|
|
|
|
|
|
sp.textContent = input.files[i].name;
|
|
|
|
|
|
box.appendChild(sp);
|
|
|
|
|
|
}
|
|
|
|
|
|
box.hidden = false;
|
2026-11-05 18:32:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-05 18:57:56 +08:00
|
|
|
|
/* #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)—— 与拖拽共用同一个渲染函数 */
|
2026-11-05 18:32:40 +08:00
|
|
|
|
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; }
|
2026-11-05 18:57:56 +08:00
|
|
|
|
cmtRenderNames(zone, t);
|
2026-11-05 18:32:40 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-22 12:40:14 +08:00
|
|
|
|
/* 拖到框上要给 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();
|
|
|
|
|
|
}
|
2026-11-05 17:59:17 +08:00
|
|
|
|
|
2026-10-22 12:40:14 +08:00
|
|
|
|
})();
|