笔记附件:修好拖放、传完回编辑态、选中即上传 #21

用户原话:「网页端,笔记应该可以同时上传多个附件。」

诊断后确认「多选」本来就是好的(multiple + name=file[] + 后端数组分支,
一次选 3 个混合类型真的存了 3 行)。真正坏的是另外两件事:

1) 拖放是假承诺。界面写「把文件拖到这里」,但没有任何 drop 监听器:
   拖完 input.files 仍是 0、POST 0 个文件。更糟的是 dragover 没有
   preventDefault ⇒ 浏览器按默认行为直接打开那个文件,正在写的笔记
   连未保存正文一起丢。
   修法:给框加 data-sd-for="noteAttInput",复用 app.js 里既有的
   bindInput() → bindZone()。一行属性,不另写一套拖放。
   踩过的坑:data-sd-for 写的是**裸 id**,第一版带了个 `#`,选择器
   变成 [data-sd-for="#noteAttInput"],一个都匹配不上,拖放照样没人接。

2) 传完跳回阅读态(redirect 不带 edit=1),附件区在 $editMode 分支里
   根本不渲染 ⇒ 用户看到的是"没传上"。attach_add / attach_del 两处
   都补 edit => 1。

另外补了即时反馈:change 一来就把提示换成「已选择 N 个文件:…,正在
上传…」并自动提交。用 requestSubmit(btn) 而不是 form.submit() ——
后者绕过 submit 事件、也不带按钮的 formaction/formenctype,提交会跑去
notes/save 且非 multipart,文件直接丢。

判据:
- _b40_note_multi_attach.py  16/0(点选多选 / 拖放 / 回编辑态 / 单选回归 / 提示文案)
- _b40_falsify21.py  三处注入各自红在预期编号(data-sd-for 带# → ③④a-d;
  requestSubmit→submit → ①-b;去掉 edit=1 → ③-a/b)
- _b40_regress21.py  20/0(删除生效且回编辑态 / 越权删除被拒 / 公共绑定器
  未受影响 / MAX_PER_HOST=60 端到端仍生效)
- _b40_regress21_falsify.py  关掉上限与归属校验各自变红
This commit is contained in:
2026-10-28 20:13:22 +08:00
parent 4941e9db9c
commit 7c86859ab9
2 changed files with 115 additions and 5 deletions
+10 -2
View File
@@ -8111,7 +8111,12 @@ $want = array_keys($want);
$warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me); $warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me);
flash($warns ? 'err' : 'ok', flash($warns ? 'err' : 'ok',
$warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('notes/form', ['id' => $id]); /* 🔴 工单 #21:回**编辑态**(`edit=1`),不是阅读态。
`notes/form` 的 `$editMode = $id <= 0 || !empty($_GET['edit'])` ⇒
原来这里不带 edit 参数,id>0 就落到阅读态,附件区(在 $editMode 分支里)
根本不渲染。用户上传完看到的是"附件没传上"——体感就是功能坏了,
实际只是跳错了态。 */
redirect('notes/form', ['id' => $id, 'edit' => 1]);
break; break;
case 'notes/attach_del': case 'notes/attach_del':
@@ -8134,7 +8139,10 @@ $want = array_keys($want);
} }
\App\Attachment::remove($aid); \App\Attachment::remove($aid);
flash('ok', '附件已删除。'); flash('ok', '附件已删除。');
redirect('notes/form', ['id' => $nid]); /* 🔴 工单 #21:删完同样回**编辑态** —— 与 attach_add 保持一致的道理:
回阅读态的话,用户点「✕」删掉一个,跳转后看不见附件列表,
不知道删成功了没有,也找不到继续删/继续传的入口。 */
redirect('notes/form', ['id' => $nid, 'edit' => 1]);
break; break;
/* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。 /* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。
+105 -3
View File
@@ -408,10 +408,40 @@ $imgParts = static function (string $c): array {
<div id="noteAttForm"> <div id="noteAttForm">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop"> <?php /* 🔴 工单 #21(用户 2026-10-28):「网页端,笔记应该可以同时上传多个附件。」
实测查清的三件事(别凭印象改):
① **多选本来就是好的**:`multiple` + `name="file[]"` + 后端
`attach_save_files()` 那段数组分支,一次选 3 个(txt/md/png 混合)
真的存了 3 行。这条不用动。
② **拖放是假承诺**:上面那句「把文件拖到这里」从来没人接。
`app.js` 的 `scan()` 会给每个 file input 挂 `bindZone(input, input)`,
但落点绑在 **input 自己**身上 —— 而用户拖的是**外层这个框**,
事件发生在框上、不会反向冒泡到 input ⇒ input.files 永远是 0。
更糟的是没有 `dragover` 的 preventDefault,浏览器按默认行为
**直接打开那个文件**,正在写的笔记连未保存的正文一起丢。
③ 传完跳回**阅读态**(见 index.php 的 attach_add),用户看不见刚传的附件,
体感就是"没传上"。
修法:给框加 `data-sd-for="noteAttInput"` —— 这是项目里既有的约定
(`bindInput()` 见到它就 `bindZone(box, input)` + `bindBox`),
一行属性就能复用成熟实现,不另写一套拖放。
🔴🔴 `data-sd-for` 里写的是**裸 id,不带 `#`**!
取值处是 `querySelectorAll('[data-sd-for="' + input.id + '"]')`,
而 `input.id` 是 `noteAttInput`。我第一版写成 `#noteAttInput`,
选择器就变成 `[data-sd-for="#noteAttInput"]` —— 一个都匹配不上,
拖放照样没人接(判据 ④-a/④-b 直接红)。**照抄 _stage_drop.php:135
的写法**(那里是 `data-sd-for="<?= e($sdId) ?>"`,$sdId 是裸 id)。
⚠️ 不能改成 `data-drop-stage`(暂存式):那套要配合 `upload_stage.php`
与 `stage_token[]` 认领,笔记这边是"直接 POST 到 attach_add",两套不能混。
⚠️ `bindFileEcho` 只认 `.upbox-msg` / `.upbox-ico`,本框用的是 `.note-att-tip`,
所以它插不上手 —— 选中后的即时反馈由下面那段小脚本自己负责。 */ ?>
<div class="note-att-drop" id="noteAttDrop" data-sd-for="noteAttInput">
<input type="file" name="file[]" id="noteAttInput" multiple <input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx"> accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span> <span class="note-att-tip" data-note-att-tip>把文件拖到这里,或<label for="noteAttInput">点这里选文件</label>(可一次选多个)</span>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存", <?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存",
@@ -448,8 +478,80 @@ $imgParts = static function (string $c): array {
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* —— <?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode( <script type="application/json" id="noteSeed"><?php echo json_encode(
$blocks, $blocks,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script> ) ?></script>
<?php endif; ?>
<?php if ($isEditing): ?>
<script>
/* ============================================================================
笔记附件:选中 / 拖入多个文件后**立刻**给出反馈,并自动提交上传(工单 #21)
----------------------------------------------------------------------------
用户原话:「网页端,笔记应该可以同时上传多个附件。」
拖放本身不在这里做 —— `app.js` 的 `bindInput()` 靠框上的
`data-sd-for="noteAttInput"` 已经挂好了 `bindZone`,会把 drop 的文件
写进 `input.files` 并派发一次 `change`。**本段只做两件事**:
① `change` 一来就把提示文案换成「已选择 N 个文件」——
否则用户点完/拖完看不到任何变化,会再点一遍(重复选同一批还会重叠)。
② 顺手自动提交到 `notes/attach_add`:以前要再点一次「上传所选文件」,
多选时这个多余的步骤特别容易漏点,漏点就等于"没传上"。
⚠️ 为什么用 `requestSubmit(btn)` 而不是 `form.submit()`:
`form.submit()` 会**绕过** submit 事件(`note_editor.js` 在 submit 里
serialize 正文块),而且不会带上按钮的 `formaction`/`formenctype`——
那样这次提交会跑去 `notes/save` 且不是 multipart,文件直接丢。
`requestSubmit(btn)` 会带着那颗按钮的 `formaction` 走,语义正确。
⚠️ 老浏览器没有 `requestSubmit` 就退回 `form.submit()` 之前先手动把
action/enctype 改对——但更简单的做法是让用户点那颗按钮(下面兜底提示)。
⚠️ 提交前把 `data-sd-echo`/拖放高亮之类收尾交给 app.js,这里不碰。
============================================================================ */
(function () {
var input = document.getElementById('noteAttInput');
var form = document.getElementById('noteForm');
var tip = document.querySelector('[data-note-att-tip]');
if (!input || !form || !tip) { return; }
/* 原始文案要记下来:用户取消选择时要还原(不能变成空字符串) */
var original = tip.innerHTML;
var btn = document.querySelector('#noteAttForm button[formaction]');
function paint() {
var n = input.files ? input.files.length : 0;
if (n === 0) { tip.innerHTML = original; return; }
var names = Array.prototype.slice.call(input.files, 0, 3)
.map(function (f) { return f.name; }).join('、');
tip.textContent = '已选择 ' + n + ' 个文件:' + names + (n > 3 ? ' 等' : '')
+ ',正在上传…';
}
input.addEventListener('change', function () {
paint();
/* 没选到东西(用户点取消)就别提交了 */
if (!input.files || input.files.length === 0) { return; }
if (!btn) { return; }
/* requestSubmit 会**带上这颗按钮的 formaction** 触发完整提交流程;
form.submit() 不会 —— 见上面那条 ⚠️。 */
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn);
} else {
/* 兜底:把 action/enctype 临时改成附件端点再提交。
⚠️ 改完**不还原**——页面马上就要跳走了,还原反而可能在
"跳转前的最后一刻"被别的代码读到错误状态。 */
form.setAttribute('action', btn.getAttribute('formaction'));
form.setAttribute('enctype', 'multipart/form-data');
form.submit();
}
});
/* 上传回来(页面重载)时把提示复位 —— 服务端渲染的是原始文案,
不需要额外做;这里只在"从往返缓存恢复"的极端情况下兜一下。 */
window.addEventListener('pageshow', function (ev) {
if (ev.persisted) { paint(); }
});
})();
</script>
<?php endif; ?> <?php endif; ?>