Files
FamilyBook/assets/js/task_panel.js
T
laoyang 40fd6c501c 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 全过
2026-10-22 12:40:14 +08:00

320 lines
14 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; }
}
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();
}
})();