v78 服务端:首页任务改成「就地处理」面板 + 新建任务搬到首页

用户原话:
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
  待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
  其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
「另外,在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
  搬过来,用弹出框来处理。」

一、首页任务浮层由「只读信息」改成「可操作面板」
- 新增 templates/tasks/_panel.php:状态三段 / 看板 chips(带看板色)/
  子任务(复用 _task_check.php)/ 附件拖放框(复用 _attachments.php)/ 讨论
- 新增路由 tasks/panel:只吐一块 HTML(找不到 404)
- 既有七个动作路由各加一条 ajax_panel 回包分支(状态 / 挪分组 / 新建子任务 /
  评论增删 / 附件增删),没有新开写接口
- helpers.php 新增 task_panel_wanted() / task_panel_json();
  fb_entry_json() 的 payload 增加 panel 字段(只有任务会给)
- 每次动作回**整块新面板**:一次动作会牵动子任务计数、附件张数、看板选中态,
  逐处同步 DOM 必然漏一处
- 拖进来的附件**立刻上传**(任务已存在,不该再让用户点一次保存),
  不复用 _stage_drop.php 那套「暂存 + 认领」

二、新建任务搬到首页
- 从 templates/tasks/index.php 抽出公共片段 templates/tasks/_add_modal.php
  (字段与原来一字未改),首页与任务页共用同一份
- 新增 assets/js/task_add.js 管开关(两页共用,幂等绑定);
  打开那一刻才 fbUpgradeRange() + fbBootAntd()(hidden 时宽高为 0,antd 会定位错乱)
- App\HomeCards 登记表里加 add => 新建任务,首页卡头与「全部任务」并排
- tasks/create 支持 back=home(白名单),新建完回首页

三、其它
- assets/js/task_panel.js:事件委托;动作后整块换面板;
  顺手同步首页那张卡的状态徽标与复选框(关掉浮层不能还是旧状态);
  传附件时保住用户写了一半的留言与子任务标题;取不到面板时退回只读信息
- app.css 追加面板样式(含 color-mix 的 @supports 兜底)与卡片窄到宽 2 时允许折行

验证:本地 e2e 1558/0、几何 428/0 + 660/0、v78 反向验证 11/0、
片段作用域体检 35/0、php -l 与 node --check 全过
This commit is contained in:
2026-10-22 12:40:14 +08:00
parent 036e208809
commit 40fd6c501c
13 changed files with 1216 additions and 178 deletions
+73
View File
@@ -6615,3 +6615,76 @@ h5.md-h, h6.md-h { font-size: 14px; }
.hc-set-dim .select { width: 66px; }
.hc-set-n { width: 64px; }
.hc-set-null { width: 0; }
/* ============================================================
v78(用户 2026-10-22):首页「近期任务」的就地面板
------------------------------------------------------------
用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,
可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,
添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
这一段只画**面板本体**(壳、遮罩、底栏都沿用 `.cal-modal*` / `.fb-entry-panel`)。
三块强调色都不新造:状态用既有的 `.kb-st`(三档颜色是它定义的唯一来源),
看板 chip 用看板自己的 `--bc`(与看板列头、卡片上的点同一个色)。
============================================================ */
.tk-panel { display: flex; flex-direction: column; gap: 14px; }
.tk-sec { display: flex; flex-direction: column; gap: 8px; }
/* 段落之间的那条细线:面板里段落多(状态/看板/信息/子任务/附件/讨论),
不划线会糊成一整块;用 line-2(比正文分隔线更淡)以免像个表单边框。 */
.tk-sec + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; }
.tk-sec-h {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; color: var(--ink);
}
.tk-sec-h .group-n { margin-left: 0; }
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
.tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
.tk-seg .kb-st { padding: 3px 12px; font-size: 12.5px; }
.tk-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.tk-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface);
font-size: 12px; color: var(--muted); cursor: pointer;
max-width: 100%;
}
.tk-chip:hover { border-color: var(--brand-2); color: var(--brand-ink); }
/* 选中的看板:用**它自己的颜色**描边 + 淡底 —— 与看板列头那颗点是同一个色,
用户不用读字就知道"这条现在在哪一摊事里"。 */
.tk-chip.is-on {
border-color: var(--bc, var(--brand));
background: color-mix(in srgb, var(--bc, var(--brand)) 12%, transparent);
color: var(--ink); font-weight: 600;
}
/* ⚠️ color-mix 在老浏览器上整条声明无效 → 底色会是透明的。
补一条纯 rgba 兜底(不指定色,统一淡灰),保证"选中"至少看得出来。 */
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
.tk-chip.is-on { background: var(--line-2); }
}
.tk-chip.is-static { cursor: default; }
.tk-chip-dot {
width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto;
background: var(--bc, var(--faint));
}
/* 只读那几行(截止/负责人/分类/备注):面板里空间紧,行高比详情页收一点 */
.tk-kv .kv-row { padding: 6px 0; }
/* 加子任务那一行:输入框吃掉剩余宽度,按钮不缩 */
.tk-inline-form { display: flex; align-items: center; gap: 8px; }
.tk-inline-form .input { flex: 1 1 auto; min-width: 0; }
.tk-inline-form .btn { flex: 0 0 auto; }
/* 讨论里那条"删除":长得像链接(`.btn-inline` 负责去掉按钮的灰底,
`.link-quiet` 负责颜色),这里只把它推到本行最右。 */
.cmt-who .cmt-x { margin-left: auto; }
.tk-drop { margin-top: 4px; }
.tk-drop .upbox { min-height: 64px; }
@media (max-width: 640px) {
.tk-sec-lb { flex: 0 0 auto; width: 100%; }
}
/* v78:卡片窄到"宽 2"时,标题 +「全部任务」+「新建任务」三样挤不下 ——
允许折行,别把标题压成 0 宽(那会只剩两颗按钮,看不出这是哪张卡片)。 */
.hc-card.hc-w2 .hc-head { flex-wrap: wrap; }
+44 -1
View File
@@ -42,6 +42,11 @@
就地摘一条会漏;且这条系列在视图里可能横跨多天),以及
拿不到 JSON(老服务端 / 出错)。
⑦ **Esc / 点遮罩只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
⑧ **v78 新增"面板态"**(任务专属):`data-fb-entry` 里带 `panel` 时,正文换成
`tasks/panel` 吐的那块**可操作面板**(状态 / 看板 / 子任务 / 附件 / 评论
都能就地改),底栏仍是「详情」那颗(跳任务详情页)。
面板里的动作由 `task_panel.js` 委托处理 —— **别在这里加**:
两个文件各管一段,谁也不会把谁的监听覆盖掉。
⚠️ 本脚本**全站加载**(layout.php):三处入口分散在不同页面,按需加载的判据
会在这些页面之间来回漏;它只有几 KB,不值得为省这点流量赌漏加载。
============================================================================ */
@@ -120,7 +125,9 @@
var isEvent = (data.source === 'event');
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
if (panelEl()) { panelEl().classList.remove('is-wide'); }
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
if (panelEl()) { panelEl().classList.toggle('is-wide', !!data.panel); }
setTip('');
/* ⚠️ 顺序要紧:**先** footShow('detail')(它会把所有按钮收掉、只点亮「详情」),
@@ -146,6 +153,42 @@
if (ed) { ed.hidden = !(isEvent && data.edit); }
var de = byId('fbEntryDelete');
if (de) { de.hidden = !(isEvent && data.del); }
/* 🔴 v78:任务面板 —— 把正文换成"能直接动手"的那一块。
放在最后(上面的只读 rows 已经画好),取不到就保留那份只读信息。 */
if (data.panel) { loadPanel(data.panel, data); }
}
/**
* 取回任务面板并塞进正文(v78)。
*
* 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能…
* 其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
*
* ⚠️ 失败时**不要清空正文**:退回 `rows` 那份只读信息(用户至少还能看清这条是什么),
* 并且把宽度收回去 —— 一块空的宽面板比"只有信息"更像坏了。
* ⚠️ 面板里的事件全部由 `task_panel.js` 委托处理(动态换 HTML 不重绑),
* 这里只负责"取回来放进去"。
*/
function loadPanel(url, data) {
if (!url || !window.fetch) { return; }
if (bodyEl()) { bodyEl().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) {
if (!r.ok) { throw new Error('panel'); }
return r.text();
})
.then(function (html) {
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
bodyEl().innerHTML = html;
runInlineScripts(bodyEl(), html);
})
.catch(function () {
if (!bodyEl()) { return; }
bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data && data.rows) + '</dl>';
if (panelEl()) { panelEl().classList.remove('is-wide'); }
setTip('这条任务的快捷面板没载入,先看基本信息。', true);
});
}
function restoreDetail() {
+62
View File
@@ -0,0 +1,62 @@
/* ============================================================================
「新建任务」弹框的开关(v78)
----------------------------------------------------------------------------
用户原话(2026-10-22):「在全部任务旁边,放一个新建任务的功能,其实就是把任务中的
新建和编辑功能,搬过来,用弹出框来处理。」
两个入口、一张弹框:
· 任务看板页(`tasks/index.php`)页头那颗「新建任务」;
· 首页「近期任务」卡片右上角那颗(在「全部任务」右边)。
两处都是 `data-tk-add-open`,弹框本体是 `templates/tasks/_add_modal.php`。
为什么单独成一个脚本(而不是各页写一段内联):
· 内联脚本在 AJAX 换正文时**不会被重跑**(v42/v47 连着栽过两次);
· 两个入口各写一段,必然有一处忘了在**打开那一刻**再 boot 一次日期控件 ——
而弹框初始是 `hidden`,antd 面板拿到 0 尺寸会定位到屏幕左上角
(`tasks/show.php` 的编辑弹框就是为这个专门 boot 过,这里照抄同一手法)。
⚠️ 全站加载,但有幂等闸门、页面里没有这张弹框时**什么都不做**(几行判断而已)。
============================================================================ */
(function () {
'use strict';
if (window.__fbTkAddBound) { return; }
window.__fbTkAddBound = true;
function card() { return document.getElementById('taskAddCard'); }
function setOpen(on) {
var c = card();
if (!c) { return; }
c.hidden = !on;
if (!on) { return; }
/* 打开这一刻才升级日期控件(hidden 时宽高为 0,antd 面板会定位错乱)。
两步都要:先绑自建下拉(兜底),再挂 antd(正主);两个函数都自判幂等。 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('tk-title');
if (t) { t.focus(); }
}
function antdOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('[data-tk-add-open]')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('[data-tk-add-close]')) { e.preventDefault(); setOpen(false); return; }
var c = card();
/* 点遮罩关、点面板内不关 —— 与站内其它浮层同一套行为 */
if (c && t === c) { setOpen(false); }
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var c = card();
if (!c || c.hidden) { return; }
if (antdOpen()) { return; } // 让 antd 先收它自己的面板
setOpen(false);
});
})();
+319
View File
@@ -0,0 +1,319 @@
/* ============================================================================
首页任务面板 —— 就地改状态 / 挪看板 / 加子任务 / 传附件 / 发评论(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; }
}
return true;
}
/**
* 把面板里的最新状态**顺手**同步到首页那张卡上(v78)。
*
* 为什么要有这一步:面板是浮层,背后那张任务是"带着旧状态"站在那儿的。
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
* 不在这里猜(猜错比不猜更糟)。
*/
function syncRow(d) {
if (!d || !d.status) { return; }
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
if (!row) { return; }
var badge = row.querySelector('.kb-st');
if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static';
if (d.word) { badge.textContent = d.word; }
}
var chk = row.querySelector('.tk-check[data-tk-done]');
if (chk) {
chk.setAttribute('data-tk-status', d.status);
chk.classList.toggle('is-done', d.status === 'done');
chk.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
}
}
/**
* 发一个动作。
* @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();
}
})();