用户原话:「网页端,笔记应该可以同时上传多个附件。」
笔记编辑页(index.php?r=notes/form&id=N)的「附件」区,一次要能挂上多个文件; 现在表现是只能传一个(用户选中/拖入多个后,最终只多出一个)。
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)也已经处理了数组形态:
attach_save_files()
if (isset($f['name']) && is_array($f['name'])) { foreach ($f['name'] as $i => $_n) { $files[] = [...]; } }
Attachment::save() 逐个保存,MAX_PER_HOST = 60 是单条笔记的附件上限。
Attachment::save()
MAX_PER_HOST = 60
grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/ 返回空 —— 笔记附件区完全没有前端 JS,全靠原生表单提交。于是几个可能:
grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/
#noteAttDrop
note-att-drop
dragover/drop
formaction
enctype
formenctype="multipart/form-data"
#noteForm
formenctype
$_FILES
note_editor.js
form.addEventListener('submit', serialize)
preventDefault
$_FILES['file']
file[]
elseif
attachments
MAX_PER_HOST
写了个诊断脚本 _tooling/_b39_diag.py,走真实浏览器,打三处事实:input 属性、真实 POST 请求、库里落了几行。
_tooling/_b39_diag.py
编辑页(?r=notes/form&id=N&edit=1)一次选 3 个文件(txt + md + png 混合):
?r=notes/form&id=N&edit=1
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 可解码"前置断言。
imagecreatefrompng
界面上白纸黑字写着:
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>
但 grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/ 返回空—— 整个笔记附件区没有任何前端 JS,#noteAttDrop 上没挂 dragover / drop 监听器。
dragover
drop
实测:
拖放区 = {'found': True, 'hasInput': True} 拖放后新增 POST 请求 = 0 拖放后 input.files.length = 0
而且没有 dragover 的 preventDefault() ⇒ 浏览器会把 drop 当默认行为处理, 直接打开那个文件,正在编辑的笔记页就丢了(未保存的正文一起没)。 用户拖两个文件进去,最可能的结果是"页面跳走了、编辑内容也没了"。
preventDefault()
上传成功后 redirect('notes/form', ['id' => $id]),而 notes/form 的 $editMode = $id <= 0 || !empty($_GET['edit']) ⇒ id>0 且没带 edit=1 就是阅读态, 附件区(if ($isEditing) 里还在 $editMode 分支内)根本不渲染。
redirect('notes/form', ['id' => $id])
notes/form
$editMode = $id <= 0 || !empty($_GET['edit'])
if ($isEditing)
$editMode
实测:上传后 URL = notes/form&id=1722,#noteFilesCard 不存在; 手动加 &edit=1 才看到 3 条。
notes/form&id=1722
#noteFilesCard
&edit=1
用户视角就是"点上传 → 回到笔记 → 附件没了",这是"只能传一个"错觉的主要来源之一。 (另外拖放不通、一次拖多个直接跳页,也会让人觉得"只能一个个来"。)
dragenter
dataTransfer.files
#noteAttInput
input.files
DataTransfer
redirect('notes/form', ['id'=>$id, 'edit'=>1])
attach_del
commit 7c86859(已推送 main),线上两个文件 md5 与本地逐字一致。
7c86859
main
data-sd-for="noteAttInput"
templates/notes/form.php
app.js
bindInput()
bindZone()
attach_add
redirect
edit => 1
index.php
multiple + name="file[]" 一行没动 —— 它本来就是好的。
multiple
name="file[]"
data-sd-for 里写的是裸 id,不带 #。我第一版写成 #noteAttInput,取值处是 querySelectorAll('[data-sd-for="' + input.id + '"]'),于是选择器变成 [data-sd-for="#noteAttInput"] —— 一个都匹配不上,拖放照样没人接。 判据 ④-a/④-b 直接红,是这条错误最直接的证据。
data-sd-for
#
querySelectorAll('[data-sd-for="' + input.id + '"]')
[data-sd-for="#noteAttInput"]
另外 form.submit() 不能用:它绕过 submit 事件,也不带按钮的 formaction/formenctype,提交会跑去 notes/save 且非 multipart,文件直接丢。 必须 form.requestSubmit(btn)。
form.submit()
notes/save
form.requestSubmit(btn)
_b40_note_multi_attach.py
_b40_falsify21.py
requestSubmit
edit=1
_b40_regress21.py
movies/new
sd-poster
sd-photo
MAX_PER_HOST=60
_b40_regress21_falsify.py
反证脚本踩到的两件事(都已修):锚点 if (!$n0 || empty($n0['is_mine'])) { 在 index.php 里出现两次(保存 / 删附件),唯一性断言把它拦下了 —— 不拦的话会去改"保存"那条,然后报"注入后仍全绿",让我误判判据没牙齿。
if (!$n0 || empty($n0['is_mine'])) {
_tooling/_b37_inline_js_check.py 把 <script type="application/json"> 也当 JS 抽出来校验,必然报语法错。已按 type 过滤(先验证 HEAD 版本同样报错,确认是存量问题 而不是本次改动引入的)。反证过:喂坏 JS 仍 exit=1,好文件 exit=0。
_tooling/_b37_inline_js_check.py
<script type="application/json">
type
exit=1
exit=0
20261028-201330,index.php / templates/notes/form.php 线上 md5 与本地一致, 线上 grep 到 data-sd-for="noteAttInput" 与两处 edit' => 1,mtime 是真实时间 (不是 1980 时间戳,OPcache 不会误判没变)。
20261028-201330
edit' => 1
线上功能请你自己点一下确认手感:新建一条笔记 → 存下来 → 把文件拖进那个虚线框, 应该立刻变成「已选择 N 个文件…正在上传」,然后停在编辑态能看见附件列表。
No dependencies set.
The note is not visible to the blocked user.
需求
用户原话:「网页端,笔记应该可以同时上传多个附件。」
笔记编辑页(
index.php?r=notes/form&id=N)的「附件」区,一次要能挂上多个文件;现在表现是只能传一个(用户选中/拖入多个后,最终只多出一个)。
代码现状(初步阅读,待实测确认)
HTML 标记看起来已经是多文件的:
后端
attach_save_files()(index.php:345)也已经处理了数组形态:Attachment::save()逐个保存,MAX_PER_HOST = 60是单条笔记的附件上限。关键疑点(为什么要实测才敢下结论)
grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/返回空 ——笔记附件区完全没有前端 JS,全靠原生表单提交。于是几个可能:
#noteAttDrop有note-att-drop样式,但没有dragover/drop监听器 ⇒ 提示文案「把文件拖到这里」是空头承诺,拖进去浏览器会直接打开那个文件。
formaction提交时丢了enctype:按钮上写了formenctype="multipart/form-data",但主表单
#noteForm(action=notes/save)没有enctype。点「上传所选文件」时浏览器应当按
formenctype走 multipart —— 需要实测确认(若没生效,$_FILES为空,表现为"点了没反应")。
note_editor.js的form.addEventListener('submit', serialize)会在提交时序列化正文块,需确认它不会
preventDefault掉附件那条提交路径。$_FILES['file']因为 name 写成file[]之外的原因退化成单文件形态,
attach_save_files()会走elseif那个分支,只存第一个。验收标准
列表里出现 3 条,刷新后仍在,库里
attachments新增 3 行。#noteAttDrop上,松手后应当真的上传;不能出现"浏览器直接打开文件把当前编辑页丢了"。
MAX_PER_HOST行为不能被破坏)。实测诊断(先测后改,把"现状到底几个"钉死)
写了个诊断脚本
_tooling/_b39_diag.py,走真实浏览器,打三处事实:input 属性、真实 POST 请求、库里落了几行。结论一:多选本身是可用的(这条不用改)
编辑页(
?r=notes/form&id=N&edit=1)一次选 3 个文件(txt + md + png 混合):混合类型也对:图片走 photo 槽位、txt/md 原样存。
结论二:拖放是假承诺(这条要改)
界面上白纸黑字写着:
但
grep -rn 'noteAttInput|noteAttDrop|noteAttForm' assets/js/返回空——整个笔记附件区没有任何前端 JS,
#noteAttDrop上没挂dragover/drop监听器。实测:
而且没有
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 条。用户视角就是"点上传 → 回到笔记 → 附件没了",这是"只能传一个"错觉的主要来源之一。
(另外拖放不通、一次拖多个直接跳页,也会让人觉得"只能一个个来"。)
本次要做的
dragover/dragenter必须preventDefault()(否则浏览器接管),drop里把dataTransfer.files写进#noteAttInput并自动提交上传。⚠️ 关键约束:
input.files是只读的,要用DataTransfer赋给input.files;不支持
DataTransfer的老浏览器要优雅退化(提示"请点这里选文件",别报错)。redirect('notes/form', ['id'=>$id, 'edit'=>1]),让用户立刻看见刚传上的附件(这才是"上传成功"的反馈)。
⚠️ 但
attach_del删完也该回编辑态(删完看不见列表同样别扭)——两处一起改,别只改一处。
验收标准(补充)
DataTransfer时给出可操作的提示,不静默失败。收尾:已修完、已验证、已部署
commit
7c86859(已推送main),线上两个文件 md5 与本地逐字一致。实际改了什么(三处,都不大)
data-sd-for="noteAttInput"templates/notes/form.phpapp.js既有的bindInput()→bindZone(),拖放才真的有人接attach_add/attach_del的redirect补edit => 1index.phptemplates/notes/form.phpmultiple+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 / ④-crequestSubmit→form.submit()→ ①-bedit=1→ ③-a / ③-b_b40_regress21.py—— 20 / 0,且没有一条被跳过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 个文件…正在上传」,然后停在编辑态能看见附件列表。