笔记附件:一次只能传一个文件(多选/多拖后只保存了其中一个) #21

Closed
opened 2026-10-28 10:55:33 +00:00 by laoyang · 2 comments
Owner

需求

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

笔记编辑页(index.php?r=notes/form&id=N)的「附件」区,一次要能挂上多个文件;
现在表现是只能传一个(用户选中/拖入多个后,最终只多出一个)。

代码现状(初步阅读,待实测确认)

HTML 标记看起来已经是多文件的:

<input type="file" name="file[]" id="noteAttInput" multiple
       accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">

后端 attach_save_files()(index.php:345)也已经处理了数组形态:

if (isset($f['name']) && is_array($f['name'])) {
    foreach ($f['name'] as $i => $_n) { $files[] = [...]; }
}

Attachment::save() 逐个保存,MAX_PER_HOST = 60 是单条笔记的附件上限。

关键疑点(为什么要实测才敢下结论)

grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/ 返回空 ——
笔记附件区完全没有前端 JS,全靠原生表单提交。于是几个可能:

  1. 拖放根本没接:#noteAttDrop 有 note-att-drop 样式,但没有 dragover/drop
    监听器 ⇒ 提示文案「把文件拖到这里」是空头承诺,拖进去浏览器会直接打开那个文件。
  2. formaction 提交时丢了 enctype:按钮上写了 formenctype="multipart/form-data",
    但主表单 #noteForm(action=notes/save)没有 enctype。点「上传所选文件」时
    浏览器应当按 formenctype 走 multipart —— 需要实测确认(若没生效,$_FILES 为空,
    表现为"点了没反应")。
  3. note_editor.js 的 form.addEventListener('submit', serialize) 会在提交时
    序列化正文块,需确认它不会 preventDefault 掉附件那条提交路径。
  4. 选中多个后只保存了一个 —— 若 $_FILES['file'] 因为 name 写成 file[] 之外的原因
    退化成单文件形态,attach_save_files() 会走 elseif 那个分支,只存第一个。

验收标准

  1. 编辑一条笔记,在附件区一次选中 3 个文件 → 点「上传所选文件」→ 3 个附件都挂上,
    列表里出现 3 条,刷新后仍在,库里 attachments 新增 3 行。
  2. 一次选中 1 个文件 → 仍然正常工作(不能为了多选把单选弄坏)。
  3. 拖放:把 2~3 个文件拖到 #noteAttDrop 上,松手后应当真的上传;
    不能出现"浏览器直接打开文件把当前编辑页丢了"。
  4. 超过 60 个附件时给出明确提示(既有的 MAX_PER_HOST 行为不能被破坏)。
  5. 上传完回到编辑页,附件列表与库里一致;不会重复挂同一批。
  6. 非图片(PDF/zip/docx)与图片混选时都能存对(图片走 photo 槽位、其余原样存)。
## 需求 用户原话:「网页端,笔记应该可以同时上传多个附件。」 笔记编辑页(`index.php?r=notes/form&id=N`)的「附件」区,一次要能挂上**多个**文件; 现在表现是只能传一个(用户选中/拖入多个后,最终只多出一个)。 ## 代码现状(初步阅读,待实测确认) HTML 标记**看起来已经是**多文件的: ```php <input type="file" name="file[]" id="noteAttInput" multiple accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx"> ``` 后端 `attach_save_files()`(index.php:345)也**已经**处理了数组形态: ```php if (isset($f['name']) && is_array($f['name'])) { foreach ($f['name'] as $i => $_n) { $files[] = [...]; } } ``` `Attachment::save()` 逐个保存,`MAX_PER_HOST = 60` 是单条笔记的附件上限。 ## 关键疑点(为什么要实测才敢下结论) `grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/` **返回空** —— 笔记附件区**完全没有前端 JS**,全靠原生表单提交。于是几个可能: 1. **拖放根本没接**:`#noteAttDrop` 有 `note-att-drop` 样式,但没有 `dragover/drop` 监听器 ⇒ 提示文案「把文件拖到这里」是**空头承诺**,拖进去浏览器会直接打开那个文件。 2. **`formaction` 提交时丢了 `enctype`**:按钮上写了 `formenctype="multipart/form-data"`, 但主表单 `#noteForm`(action=notes/save)**没有** `enctype`。点「上传所选文件」时 浏览器应当按 `formenctype` 走 multipart —— 需要实测确认(若没生效,`$_FILES` 为空, 表现为"点了没反应")。 3. **`note_editor.js` 的 `form.addEventListener('submit', serialize)`** 会在提交时 序列化正文块,需确认它不会 `preventDefault` 掉附件那条提交路径。 4. 选中多个后只保存了一个 —— 若 `$_FILES['file']` 因为 name 写成 `file[]` 之外的原因 退化成单文件形态,`attach_save_files()` 会走 `elseif` 那个分支,只存第一个。 ## 验收标准 1. 编辑一条笔记,在附件区**一次选中 3 个文件** → 点「上传所选文件」→ 3 个附件都挂上, 列表里出现 3 条,刷新后仍在,库里 `attachments` 新增 3 行。 2. 一次选中 1 个文件 → 仍然正常工作(不能为了多选把单选弄坏)。 3. **拖放**:把 2~3 个文件拖到 `#noteAttDrop` 上,松手后应当**真的上传**; 不能出现"浏览器直接打开文件把当前编辑页丢了"。 4. 超过 60 个附件时给出明确提示(既有的 `MAX_PER_HOST` 行为不能被破坏)。 5. 上传完回到编辑页,附件列表与库里一致;不会重复挂同一批。 6. 非图片(PDF/zip/docx)与图片混选时都能存对(图片走 photo 槽位、其余原样存)。
Author
Owner

实测诊断(先测后改,把"现状到底几个"钉死)

写了个诊断脚本 _tooling/_b39_diag.py,走真实浏览器,打三处事实:input 属性、真实 POST 请求、库里落了几行。

结论一:多选本身是可用的(这条不用改)

编辑页(?r=notes/form&id=N&edit=1)一次选 3 个文件(txt + md + png 混合):

input.files.length = 3
POST → index.php?r=notes%2Fattach_add
content-type = multipart/form-data; boundary=...
库里:38 i21-a.txt / 39 i21-b.md / 40 i21-c.png
实际附件数 = 3(期望 3)
页面显示 = 3 条

混合类型也对:图片走 photo 槽位、txt/md 原样存。

插一句排查记录:第一次跑出「3 个只存了 2 个、png 丢了」,我差点当成产品 bug。
真相是我手搓的 1x1 PNG 字节 imagecreatefrompng 解不开(实测 FAIL),
换成 GD 真生成的就 OK。夹具问题别记成产品问题——已经给诊断脚本加了
"png 可解码"前置断言。

结论二:拖放是假承诺(这条要改)

界面上白纸黑字写着:

<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>

但 grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/ 返回空——
整个笔记附件区没有任何前端 JS,#noteAttDrop 上没挂 dragover / drop 监听器。

实测:

拖放区 = {'found': True, 'hasInput': True}
拖放后新增 POST 请求 = 0
拖放后 input.files.length = 0

而且没有 dragover 的 preventDefault() ⇒ 浏览器会把 drop 当默认行为处理,
直接打开那个文件,正在编辑的笔记页就丢了(未保存的正文一起没)。
用户拖两个文件进去,最可能的结果是"页面跳走了、编辑内容也没了"。

结论三:传完跳回阅读态,看不见刚传的附件

上传成功后 redirect('notes/form', ['id' => $id]),而 notes/form 的
$editMode = $id <= 0 || !empty($_GET['edit']) ⇒ id>0 且没带 edit=1 就是阅读态,
附件区(if ($isEditing) 里还在 $editMode 分支内)根本不渲染。

实测:上传后 URL = notes/form&id=1722,#noteFilesCard 不存在;
手动加 &edit=1 才看到 3 条。

用户视角就是"点上传 → 回到笔记 → 附件没了",这是"只能传一个"错觉的主要来源之一。
(另外拖放不通、一次拖多个直接跳页,也会让人觉得"只能一个个来"。)

本次要做的

  1. 接上拖放:dragover / dragenter 必须 preventDefault()(否则浏览器接管),
    drop 里把 dataTransfer.files 写进 #noteAttInput 并自动提交上传。
    ⚠️ 关键约束:input.files 是只读的,要用 DataTransfer 赋给 input.files;
    不支持 DataTransfer 的老浏览器要优雅退化(提示"请点这里选文件",别报错)。
  2. 拖放要能一次拖多个(与多选一致),并且图片/非图片混拖也要对。
  3. 上传后回编辑态:redirect('notes/form', ['id'=>$id, 'edit'=>1]),
    让用户立刻看见刚传上的附件(这才是"上传成功"的反馈)。
    ⚠️ 但 attach_del 删完也该回编辑态(删完看不见列表同样别扭)——
    两处一起改,别只改一处。
  4. 加一个"选了 N 个文件"的即时提示,让用户点完就确认选上了几个(可选,但成本低)。

验收标准(补充)

  1. 拖 2~3 个文件到附件区松手 → 页面不跳走,附件真的传上,列表刷新后都在。
  2. 上传完停留在编辑态,能立刻看到新附件。
  3. 拖非图片(txt/pdf)与图片混拖,都存对。
  4. 不支持 DataTransfer 时给出可操作的提示,不静默失败。
## 实测诊断(先测后改,把"现状到底几个"钉死) 写了个诊断脚本 `_tooling/_b39_diag.py`,走真实浏览器,打三处事实:input 属性、真实 POST 请求、库里落了几行。 ### 结论一:多选本身**是可用的**(这条不用改) 编辑页(`?r=notes/form&id=N&edit=1`)一次选 3 个文件(txt + md + png 混合): ``` input.files.length = 3 POST → index.php?r=notes%2Fattach_add content-type = multipart/form-data; boundary=... 库里:38 i21-a.txt / 39 i21-b.md / 40 i21-c.png 实际附件数 = 3(期望 3) 页面显示 = 3 条 ``` 混合类型也对:图片走 photo 槽位、txt/md 原样存。 > 插一句排查记录:第一次跑出「3 个只存了 2 个、png 丢了」,我差点当成产品 bug。 > 真相是**我手搓的 1x1 PNG 字节 `imagecreatefrompng` 解不开**(实测 FAIL), > 换成 GD 真生成的就 OK。**夹具问题别记成产品问题**——已经给诊断脚本加了 > "png 可解码"前置断言。 ### 结论二:**拖放是假承诺**(这条要改) 界面上白纸黑字写着: ```html <span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span> ``` 但 `grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/` **返回空**—— 整个笔记附件区**没有任何前端 JS**,`#noteAttDrop` 上没挂 `dragover` / `drop` 监听器。 实测: ``` 拖放区 = {'found': True, 'hasInput': True} 拖放后新增 POST 请求 = 0 拖放后 input.files.length = 0 ``` 而且没有 `dragover` 的 `preventDefault()` ⇒ 浏览器会把 drop 当默认行为处理, **直接打开那个文件**,正在编辑的笔记页就丢了(未保存的正文一起没)。 用户拖两个文件进去,最可能的结果是"页面跳走了、编辑内容也没了"。 ### 结论三:传完跳回**阅读态**,看不见刚传的附件 上传成功后 `redirect('notes/form', ['id' => $id])`,而 `notes/form` 的 `$editMode = $id <= 0 || !empty($_GET['edit'])` ⇒ **id>0 且没带 edit=1 就是阅读态**, 附件区(`if ($isEditing)` 里还在 `$editMode` 分支内)根本不渲染。 实测:上传后 URL = `notes/form&id=1722`,`#noteFilesCard` 不存在; 手动加 `&edit=1` 才看到 3 条。 用户视角就是"点上传 → 回到笔记 → 附件没了",**这是"只能传一个"错觉的主要来源之一**。 (另外拖放不通、一次拖多个直接跳页,也会让人觉得"只能一个个来"。) ### 本次要做的 1. **接上拖放**:`dragover` / `dragenter` 必须 `preventDefault()`(否则浏览器接管), `drop` 里把 `dataTransfer.files` 写进 `#noteAttInput` 并**自动提交**上传。 ⚠️ 关键约束:`input.files` 是只读的,要用 `DataTransfer` 赋给 `input.files`; 不支持 `DataTransfer` 的老浏览器要优雅退化(提示"请点这里选文件",别报错)。 2. **拖放要能一次拖多个**(与多选一致),并且图片/非图片混拖也要对。 3. **上传后回编辑态**:`redirect('notes/form', ['id'=>$id, 'edit'=>1])`, 让用户立刻看见刚传上的附件(这才是"上传成功"的反馈)。 ⚠️ 但 `attach_del` 删完也该回编辑态(删完看不见列表同样别扭)—— 两处一起改,别只改一处。 4. 加一个"选了 N 个文件"的即时提示,让用户点完就确认选上了几个(可选,但成本低)。 ### 验收标准(补充) 7. 拖 2~3 个文件到附件区松手 → **页面不跳走**,附件真的传上,列表刷新后都在。 8. 上传完停留在编辑态,能立刻看到新附件。 9. 拖非图片(txt/pdf)与图片混拖,都存对。 10. 不支持 `DataTransfer` 时给出可操作的提示,不静默失败。
Author
Owner

收尾:已修完、已验证、已部署

commit 7c86859(已推送 main),线上两个文件 md5 与本地逐字一致。

实际改了什么(三处,都不大)

改动 文件 说明
附件框加 data-sd-for="noteAttInput" templates/notes/form.php 复用 app.js 既有的 bindInput() → bindZone(),拖放才真的有人接
attach_add / attach_del 的 redirect 补 edit => 1 index.php 传完/删完停在编辑态,用户能看见附件列表
选中/拖入后即时提示 + 自动提交 templates/notes/form.php 提示变「已选择 N 个文件:…,正在上传…」,不用再补点一次按钮

multiple + name="file[]" 一行没动 —— 它本来就是好的。

一个必须记下来的坑

data-sd-for 里写的是裸 id,不带 #。我第一版写成 #noteAttInput,取值处是
querySelectorAll('[data-sd-for="' + input.id + '"]'),于是选择器变成
[data-sd-for="#noteAttInput"] —— 一个都匹配不上,拖放照样没人接。
判据 ④-a/④-b 直接红,是这条错误最直接的证据。

另外 form.submit() 不能用:它绕过 submit 事件,也不带按钮的
formaction/formenctype,提交会跑去 notes/save 且非 multipart,文件直接丢。
必须 form.requestSubmit(btn)。

判据与反证

  • _b40_note_multi_attach.py —— 16 / 0
    点选多选(txt+md+png 三个真存 3 行)· 拖放 2 个(页面不跳走、真入库)·
    传后回编辑态(URL 带 edit=1 且页面上真能看见 3 行)· 单选仍正常 · 提示文案随选择变
  • _b40_falsify21.py —— 三处注入,各自红在预期编号
    • data-sd-for 带 # → ③ / ④-a / ④-b / ④-c
    • requestSubmit → form.submit() → ①-b
    • 去掉 edit=1 → ③-a / ③-b
  • _b40_regress21.py —— 20 / 0,且没有一条被跳过
    • 删附件生效、且删完回编辑态;断言"删的是指定的那一个"(只数总数会漏)
    • 越权删除被拒(造了 person_id=2 的笔记 + 2 个附件做夹具,断言"附件确实还在")
    • 公共绑定器 bindInput() 没被弄坏(movies/new 的 sd-poster/sd-photo 仍挂得上)
    • MAX_PER_HOST=60 端到端仍生效:塞 59 行 → 第 60 个能传 → 第 61 个被拒
  • _b40_regress21_falsify.py —— 关掉上限、关掉归属校验,各自变红

反证脚本踩到的两件事(都已修):锚点 if (!$n0 || empty($n0['is_mine'])) {
在 index.php 里出现两次(保存 / 删附件),唯一性断言把它拦下了 ——
不拦的话会去改"保存"那条,然后报"注入后仍全绿",让我误判判据没牙齿。

顺带修的存量误报

_tooling/_b37_inline_js_check.py 把 <script type="application/json"> 也当 JS
抽出来校验,必然报语法错。已按 type 过滤(先验证 HEAD 版本同样报错,确认是存量问题
而不是本次改动引入的)。反证过:喂坏 JS 仍 exit=1,好文件 exit=0。

部署

20261028-201330,index.php / templates/notes/form.php 线上 md5 与本地一致,
线上 grep 到 data-sd-for="noteAttInput" 与两处 edit' => 1,mtime 是真实时间
(不是 1980 时间戳,OPcache 不会误判没变)。

线上功能请你自己点一下确认手感:新建一条笔记 → 存下来 → 把文件拖进那个虚线框,
应该立刻变成「已选择 N 个文件…正在上传」,然后停在编辑态能看见附件列表。

## 收尾:已修完、已验证、已部署 commit `7c86859`(已推送 `main`),线上两个文件 md5 与本地逐字一致。 ### 实际改了什么(三处,都不大) | 改动 | 文件 | 说明 | |---|---|---| | 附件框加 `data-sd-for="noteAttInput"` | `templates/notes/form.php` | 复用 `app.js` 既有的 `bindInput()` → `bindZone()`,拖放才真的有人接 | | `attach_add` / `attach_del` 的 `redirect` 补 `edit => 1` | `index.php` | 传完/删完停在编辑态,用户能看见附件列表 | | 选中/拖入后即时提示 + 自动提交 | `templates/notes/form.php` | 提示变「已选择 N 个文件:…,正在上传…」,不用再补点一次按钮 | `multiple` + `name="file[]"` 一行没动 —— 它本来就是好的。 ### 一个必须记下来的坑 `data-sd-for` 里写的是**裸 id,不带 `#`**。我第一版写成 `#noteAttInput`,取值处是 `querySelectorAll('[data-sd-for="' + input.id + '"]')`,于是选择器变成 `[data-sd-for="#noteAttInput"]` —— 一个都匹配不上,拖放照样没人接。 判据 ④-a/④-b 直接红,是这条错误最直接的证据。 另外 `form.submit()` 不能用:它绕过 submit 事件,也不带按钮的 `formaction`/`formenctype`,提交会跑去 `notes/save` 且非 multipart,文件直接丢。 必须 `form.requestSubmit(btn)`。 ### 判据与反证 - `_b40_note_multi_attach.py` —— **16 / 0** 点选多选(txt+md+png 三个真存 3 行)· 拖放 2 个(页面不跳走、真入库)· 传后回编辑态(URL 带 edit=1 且页面上真能看见 3 行)· 单选仍正常 · 提示文案随选择变 - `_b40_falsify21.py` —— 三处注入,各自红在预期编号 - `data-sd-for` 带 `#` → ③ / ④-a / ④-b / ④-c - `requestSubmit` → `form.submit()` → ①-b - 去掉 `edit=1` → ③-a / ③-b - `_b40_regress21.py` —— **20 / 0**,且**没有一条被跳过** - 删附件生效、且删完回编辑态;断言"删的是指定的那一个"(只数总数会漏) - 越权删除被拒(造了 person_id=2 的笔记 + 2 个附件做夹具,断言"附件确实还在") - 公共绑定器 `bindInput()` 没被弄坏(`movies/new` 的 `sd-poster`/`sd-photo` 仍挂得上) - `MAX_PER_HOST=60` **端到端**仍生效:塞 59 行 → 第 60 个能传 → 第 61 个被拒 - `_b40_regress21_falsify.py` —— 关掉上限、关掉归属校验,各自变红 反证脚本踩到的两件事(都已修):锚点 `if (!$n0 || empty($n0['is_mine'])) {` 在 `index.php` 里出现**两次**(保存 / 删附件),唯一性断言把它拦下了 —— 不拦的话会去改"保存"那条,然后报"注入后仍全绿",让我误判判据没牙齿。 ### 顺带修的存量误报 `_tooling/_b37_inline_js_check.py` 把 `<script type="application/json">` 也当 JS 抽出来校验,必然报语法错。已按 `type` 过滤(先验证 HEAD 版本同样报错,确认是存量问题 而不是本次改动引入的)。反证过:喂坏 JS 仍 `exit=1`,好文件 `exit=0`。 ### 部署 `20261028-201330`,`index.php` / `templates/notes/form.php` 线上 md5 与本地一致, 线上 grep 到 `data-sd-for="noteAttInput"` 与两处 `edit' => 1`,mtime 是真实时间 (不是 1980 时间戳,OPcache 不会误判没变)。 线上功能请你自己点一下确认手感:新建一条笔记 → 存下来 → 把文件拖进那个虚线框, 应该立刻变成「已选择 N 个文件…正在上传」,然后停在编辑态能看见附件列表。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#21