评论附件支持拖拽 + 首页卡片同步标题 (#79)
用户 2026-11-05: 「评论框下面的附件也需要可以拖动添加。」 「首页卡片没有刷新。」 ① 评论附件拖拽 给 `.cmt-attach-row` 挂 `data-cmt-drop`,由 task_panel.js 接管 dragover / dragenter / dragleave / 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 判重,否则"只拖一次却传上去两张一样的"。 ⚠️ dragover 必须 preventDefault(不拦的话浏览器把文件当"打开", 直接跳走、当前页面全丢)。 ⚠️ 与「任务附件」那条 `[data-tk-drop-box]` 是**两条独立的路**: 那个是"点了/拖了立刻上传"(任务已存在), 这个是"挂到评论表单、点发表才一起提交"。 实测(Playwright + 真实 DataTransfer): 拖入 a.txt+b.png => count=2 ✅ 再拖 c.jpg => count=3 ✅ 累加 重复拖 a.txt => count=3 ✅ 去重 FormData(form) => ["a.txt","b.png","c.jpg"] ✅ 提交时真会传 ② 首页卡片同步标题 根因:`syncRow()` 原来**只管状态徽标和复选框**,代码第一行就是 `if (!d || !d.status) return;`,压根不碰标题。 而 #78 之后面板里能改标题了 ⇒ 改完点保存,卡片上还是旧标题。 改两处: · `task_panel_json()` 返回值补 `title`(服务端直接给,不依赖 DOM 还在) · `syncRow()` 补标题同步:`.dash-what` 的文本 + `title` 属性 (`title` 是悬停提示,不同步会出现"悬停是旧标题"的怪事), 并把 `data-fb-entry` 里的 JSON title 一并改掉 (否则"卡片上是新标题、再点开浮层显示旧标题")。 读不到 d.title 时兜底从 `#tkp-title` 输入框读。
This commit is contained in:
@@ -599,6 +599,11 @@ function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
|
|||||||
'id' => $taskId,
|
'id' => $taskId,
|
||||||
'status' => (string)($t['status'] ?? ''),
|
'status' => (string)($t['status'] ?? ''),
|
||||||
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
||||||
|
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
|
||||||
|
前端 `syncRow()` 原来只同步状态徽标,不同步标题。
|
||||||
|
标题可以从面板输入框读,但那条路依赖 DOM 还在 ——
|
||||||
|
服务端直接回一份才是稳的(同一个函数给所有面板动作复用)。 */
|
||||||
|
'title' => (string)($t['title'] ?? ''),
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -6987,6 +6987,18 @@ h6.md-h { font-size: 17px; color: var(--muted); }
|
|||||||
font-size: 12px; color: var(--faint);
|
font-size: 12px; color: var(--faint);
|
||||||
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* #79:评论附件区支持拖拽 —— 高亮用虚线框 + 浅主题底色,和上面那块
|
||||||
|
「任务附件」的 is-over 观感一致(同一个交互不该长两个样子)。 */
|
||||||
|
.cmt-attach-row {
|
||||||
|
padding: 6px 8px; border-radius: 8px;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
transition: background-color .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.cmt-attach-row.is-over {
|
||||||
|
border-color: var(--brand);
|
||||||
|
background: color-mix(in srgb, var(--brand) 8%, transparent);
|
||||||
|
}
|
||||||
|
.cmt-attach-row.is-over .cmt-attach-pick { color: var(--brand); border-color: var(--brand); }
|
||||||
.tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
|
.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 { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
|
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
|
||||||
|
|||||||
@@ -131,6 +131,36 @@
|
|||||||
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
||||||
if (!row) { return; }
|
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` **之前**读 —— 见上面最后一条 ⚠️。 */
|
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
|
||||||
var chk0 = row.querySelector('.tk-check[data-tk-done]');
|
var chk0 = row.querySelector('.tk-check[data-tk-done]');
|
||||||
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
|
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
|
||||||
@@ -490,6 +520,117 @@
|
|||||||
if (inp) { inp.click(); }
|
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) { /* 个别内核只读:下面提示照旧 */ }
|
||||||
|
|
||||||
|
/* 更新提示文字(用户要能确认"确实加上了哪几个") */
|
||||||
|
var nameEl = zone.querySelector('[data-cmt-file-name]');
|
||||||
|
if (nameEl) {
|
||||||
|
var names = [];
|
||||||
|
if (input.files) {
|
||||||
|
for (var k = 0; k < input.files.length; k++) { names.push(input.files[k].name); }
|
||||||
|
}
|
||||||
|
nameEl.textContent = names.length
|
||||||
|
? names.join('、')
|
||||||
|
: '可点击选择,也可把文件拖进来';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 点选也要更新提示(原生 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; }
|
||||||
|
var nameEl = zone.querySelector('[data-cmt-file-name]');
|
||||||
|
if (!nameEl) { return; }
|
||||||
|
var names = [];
|
||||||
|
for (var i = 0; i < (t.files ? t.files.length : 0); i++) { names.push(t.files[i].name); }
|
||||||
|
nameEl.textContent = names.length ? names.join('、') : '可点击选择,也可把文件拖进来';
|
||||||
|
});
|
||||||
|
|
||||||
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
|
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
|
||||||
document.addEventListener('dragover', function (e) {
|
document.addEventListener('dragover', function (e) {
|
||||||
var t = e.target;
|
var t = e.target;
|
||||||
|
|||||||
@@ -435,12 +435,23 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
|||||||
占位文案,见 comment_add 的处理。
|
占位文案,见 comment_add 的处理。
|
||||||
⚠️ 用原生 `<input type=file>` 而不是拖拽框:评论是一次性提交,
|
⚠️ 用原生 `<input type=file>` 而不是拖拽框:评论是一次性提交,
|
||||||
拖拽框那套(`data-tk-drop-box`)是"点了立刻上传",两者语义不同。 */ ?>
|
拖拽框那套(`data-tk-drop-box`)是"点了立刻上传",两者语义不同。 */ ?>
|
||||||
<div class="cmt-attach-row">
|
<?php /* 🔴 #79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
|
||||||
|
|
||||||
|
⇒ 给这片区域挂上拖拽落点(`data-cmt-drop`,由 task_panel.js 接管)。
|
||||||
|
两个要点:
|
||||||
|
① `dragover` 必须 `preventDefault()` —— 不拦的话浏览器
|
||||||
|
会把文件当"打开"处理(直接跳走,当前页面全丢)。
|
||||||
|
② 拖进来的文件**要写回真正的 `<input type=file>`** ——
|
||||||
|
`<input>` 的 `files` 是只读的,得用 `DataTransfer` 重建
|
||||||
|
一个 FileList 赋回去。这样表单提交时浏览器才会真的带上文件,
|
||||||
|
否则只是"看起来加上了",提交是空的。 */ ?>
|
||||||
|
<div class="cmt-attach-row" data-cmt-drop>
|
||||||
<label class="cmt-attach-pick">
|
<label class="cmt-attach-pick">
|
||||||
<input type="file" name="attach[]" multiple class="sr-only">
|
<input type="file" name="attach[]" multiple class="sr-only"
|
||||||
|
id="tkp-cmt-files" data-cmt-file-input>
|
||||||
<span aria-hidden="true">📎</span> 添加附件
|
<span aria-hidden="true">📎</span> 添加附件
|
||||||
</label>
|
</label>
|
||||||
<span class="cmt-attach-name" data-cmt-file-name></span>
|
<span class="cmt-attach-name" data-cmt-file-name>可点击选择,也可把文件拖进来</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
|
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user