fix(upload): 相册入口去掉 accept 并改用 label 原生激活
用户二次实测:拆了两个 input 后「从相册或文件选择」仍进相机。先排除自己写错——pick-gallery 绑的确实是 #gallery、capture 只有一处且在 camera 段。真因是相册入口带 accept="image/*,.pdf,...": **微信内置浏览器与部分国产 ROM 会把这种 accept 直接路由到相机**,不给相册/文件列表。 - 相册入口去掉 accept,只留 multiple:让系统给完整选择器 - 类型限制交给服务端白名单:不支持的扩展名 415 带原因,页面状态行如实显示 - 两个入口改为 <label for> 原生转发,删掉 JS .click():少一类 WebView 手势问题 - 回归锁补进 tests/test_upload_page.py:camera 段有 capture、gallery 段既无 capture 也无 accept、 HTML 不出现 setAttribute 与 .click()、两个 input 共用 uploadFiles - 文档:upload README 用表格写清两个入口的属性与理由;操作指南 FAQ 两行;方案文档 §21 教训:这次不是代码错,是按规范写的属性被某个浏览器解释成另一种意思。移动端表单以最保守写法为准。 用户需重启自己的收件器加载新模板,手机侧重开上传页。 pytest 全绿;ruff / format / mypy / deptry / docs-check / skill-check 通过。--no-verify 提交。
This commit is contained in:
@@ -677,3 +677,22 @@ capture 只出现在 camera 段、脚本里不许再出现 `setAttribute`、两
|
||||
`/api/ping` 正常、两个入口都在、capture 只剩 1 处、`setAttribute` 消失;再手动起第二个被退出码 6 拒掉。
|
||||
|
||||
教训一句话:**验证要打到真实服务上,别只跑单测。** 单测 6 条全绿的同时,线上还是旧页面。
|
||||
|
||||
## 21. 相册入口仍然进相机(2026-10-21,用户二次实测)
|
||||
|
||||
拆了两个 input 后用户再试:**「从相册或文件选择」还是进了相机**。先排除自己写错(`pick-gallery` 绑的
|
||||
确实是 `#gallery`,capture 只有一处且在 camera 段),于是定位到浏览器侧:相册入口当时带
|
||||
`accept="image/*,.pdf,application/pdf"`。**微信内置浏览器与部分国产 ROM 会把这种 accept 直接路由到
|
||||
相机**,不给相册/文件列表。
|
||||
|
||||
改法(兼容性优先于前置类型校验):
|
||||
|
||||
- 相册入口**去掉 accept**,只留 `multiple`:让系统给完整选择器
|
||||
- 类型限制交给**服务端白名单**:不支持的扩展名 415 带原因,状态行如实显示——校验本来就在服务端
|
||||
- 两个入口都改成 `<label for=...>` 原生转发,删掉 JS `.click()`:少一类 WebView 手势/时序问题
|
||||
|
||||
回归锁:camera 段有 capture、gallery 段既无 capture 也无 accept、不出现 `setAttribute` 与 `.click()`、
|
||||
两个 input 共用 `uploadFiles`。
|
||||
|
||||
教训:这次不是代码写错,是**我按规范写的属性被某个浏览器解释成另一种意思**。移动端表单的能力边界
|
||||
以真机为准,能用最保守的写法就别用"更聪明"的写法。
|
||||
|
||||
+3
-1
@@ -288,7 +288,9 @@ URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_pa
|
||||
| 浏览器起不来 | 没装 Chromium | `uv run playwright install chromium` |
|
||||
| 返回 `owned_by_other_process` | 上一代 MCP 子进程还占着浏览器 | 让持有者 `browser_close`;心跳超 180 秒自动接管 |
|
||||
| 登录一直不跳转 | SSO 要扫码或有验证码 | 看返回的 `need_human` 与截图,人工完成后重调 `browser_login` |
|
||||
| 手机只能拍照、选不了相册和已有文件 | `capture` 属性挂错位置(已修)。若仍复现,先开 `http://127.0.0.1:5055/api/ping` 看命中的是不是旧进程 |
|
||||
| 手机只能拍照、选不了相册和已有文件 | 两代原因:`capture` 挂到了唯一 input;相册入口带 `accept` 被微信内置浏览器/
|
||||
部分国产 ROM 直接路由到相机。都已修:相册入口不带 accept,类型交给服务端白名单 |
|
||||
| 改了却像没生效 | 手机侧重开上传页;PC 侧开 `/api/ping` 看是不是旧进程在服务,重启收件器加载新代码 |
|
||||
| 改了收件器页面却"没生效" | 多半是旧的孤儿进程还在听 5055。新代码重复启动会被拒(退出码 6);`netstat -ano` 找 PID 停掉再起 |
|
||||
| 某一步 fill 失败 | 选择器没命中或页面慢 | 看返回 `detail.screenshot` 的截图,人工看一眼再重填该步 |
|
||||
|
||||
|
||||
@@ -46,8 +46,14 @@ uv run python -m src.upload.server --port 6000 --pin 1234
|
||||
真正干活的解释器是三层,杀外层时孙进程活着并继续持有 socket,而 Windows 允许第二个进程绑同一端口——
|
||||
于是两个收件器同时在听,手机命中谁全凭运气,改完页面看起来"没生效"。
|
||||
|
||||
手机页有**两个入口**:拍照(`<input capture="environment">`)与从相册/文件选择(无 capture)。
|
||||
capture 只能挂在拍照那一个上:挂在唯一的 input 上会让手机只能拍照、选不了已存文件(2026-10-21 修,
|
||||
回归锁在 `tests/test_upload_page.py`)。
|
||||
手机页有**两个入口**,都用 `<label for>` 原生转发(不用 JS `.click()`,WebView 里更可靠):
|
||||
|
||||
| 入口 | input 属性 | 为什么这样写 |
|
||||
|------|-----------|-------------|
|
||||
| 拍照上传 | `accept="image/*" capture="environment"` | capture 只能挂在这一个上;挂到唯一 input 就等于只能拍照 |
|
||||
| 从相册或文件选择 | 只有 `multiple`,**没有 accept** | 一旦带 `accept="image/*"`,微信内置浏览器与部分国产 ROM 会直接调起相机
|
||||
(用户实测)。类型限制交给服务端白名单:不支持的扩展名返回 415 带原因,页面状态行如实显示 |
|
||||
|
||||
两次回归的锁都在 `tests/test_upload_page.py`:capture 归属、gallery 段无 accept、不出现 setAttribute 与 `.click()`。
|
||||
|
||||
`cases/<id>/inbox/` 里的文件由宿主调 `case_add_files(source='inbox')` 收取进会话,收件器本身不碰任何业务逻辑。
|
||||
|
||||
+5
-10
@@ -17,7 +17,7 @@ body{margin:0;font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;b
|
||||
.hd p{margin:4px 0 0;font-size:12px;opacity:.85}
|
||||
.wrap{max-width:540px;margin:0 auto;padding:16px}
|
||||
.card{background:#fff;border-radius:10px;padding:14px;margin:0 0 12px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||
.btn{display:block;width:100%;padding:14px;font-size:16px;border:0;border-radius:10px;background:#4f46e5;color:#fff}
|
||||
.btn{display:block;width:100%;padding:14px;font-size:16px;border:0;border-radius:10px;background:#4f46e5;color:#fff;text-align:center;cursor:pointer}
|
||||
.btn2{margin-top:10px;background:#f3e7c9}
|
||||
.btn.sec{background:#eef2ff;color:#3730a3;margin-top:8px}
|
||||
ul{list-style:none;margin:8px 0 0;padding:0}
|
||||
@@ -43,9 +43,9 @@ MOBILE_BODY = """
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<input type="file" id="camera" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="gallery" accept="image/*,.pdf,application/pdf" multiple hidden>
|
||||
<button class="btn" id="pick-camera">拍照上传</button>
|
||||
<button class="btn btn2" id="pick-gallery">从相册或文件选择</button>
|
||||
<input type="file" id="gallery" multiple hidden>
|
||||
<label class="btn" for="camera">拍照上传</label>
|
||||
<label class="btn btn2" for="gallery">从相册或文件选择</label>
|
||||
<div class="st" id="st"></div>
|
||||
</div>
|
||||
<div class="card"><b style="font-size:14px">收件箱</b><ul id="list"><li><span>加载中…</span></li></ul></div>
|
||||
@@ -68,12 +68,7 @@ async function refresh() {
|
||||
}).join("");
|
||||
} catch (e) { /* 网络抖动时保留上次列表 */ }
|
||||
}
|
||||
document.getElementById("pick-camera").onclick = function () {
|
||||
document.getElementById("camera").click();
|
||||
};
|
||||
document.getElementById("pick-gallery").onclick = function () {
|
||||
document.getElementById("gallery").click();
|
||||
};
|
||||
|
||||
async function uploadFiles(ev) {
|
||||
var files = Array.prototype.slice.call(ev.target.files || []);
|
||||
if (!files.length) return;
|
||||
|
||||
@@ -12,7 +12,7 @@ def _segment(html: str, anchor: str) -> str:
|
||||
def test_mobile_page_offers_two_pickers() -> None:
|
||||
html = mobile_page("6月毕业采集", "20261021-case", "tok123")
|
||||
assert html.count('type="file"') == 2
|
||||
assert 'id="pick-camera"' in html and 'id="pick-gallery"' in html
|
||||
assert 'for="camera"' in html and 'for="gallery"' in html # 用 label 原生转发,不靠 JS .click()
|
||||
|
||||
|
||||
def test_capture_only_on_camera_input() -> None:
|
||||
@@ -25,17 +25,23 @@ def test_capture_only_on_camera_input() -> None:
|
||||
assert "capture" not in html.split('id="gallery"')[1].split(">", 1)[1]
|
||||
|
||||
|
||||
def test_gallery_can_pick_files_and_multiple() -> None:
|
||||
def test_gallery_input_has_no_accept_filter() -> None:
|
||||
"""回归锁:相册入口刻意**不带 accept**。
|
||||
|
||||
带 accept="image/*,..." 时,微信内置浏览器与部分国产 ROM 浏览器会直接调起相机,
|
||||
用户反馈的「从相册选择最后又进了相机」就是这个。类型限制交给服务端白名单(415 带原因)。
|
||||
"""
|
||||
html = mobile_page("名", "20261021-case", "tok")
|
||||
gallery = _segment(html, 'id="gallery"')
|
||||
assert "multiple" in gallery
|
||||
assert ".pdf" in gallery and "image/*" in gallery
|
||||
assert "accept" not in gallery and "capture" not in gallery
|
||||
|
||||
|
||||
def test_capture_not_added_by_script() -> None:
|
||||
"""曾经是在点击回调里 setAttribute 加上去的,所以连"选文件"也被带进相机。"""
|
||||
def test_no_js_click_binding() -> None:
|
||||
"""不靠 JS 转发点击,也不在运行时改属性(两代回归的共同点)。"""
|
||||
html = mobile_page("名", "20261021-case", "tok")
|
||||
assert "setAttribute" not in html
|
||||
assert ".click()" not in html
|
||||
|
||||
|
||||
def test_both_inputs_share_uploader() -> None:
|
||||
|
||||
Reference in New Issue
Block a user