---
description: "表单和详情页绕不开的形状：拖拽或点击选文件，图片出缩略图、其他文件出类型图标，可逐个移除。要点不在控件而在背后那道存储 seam——控件只认 StorageAdapter 接口，存进浏览器还是 S3 与 UI 无关"
---

# 文件上传（拖拽选择 + 存储 seam）

表单和详情页绕不开的形状：拖拽或点击选文件，图片出缩略图、其他文件出类型图标，可逐个移除。要点不在控件而在背后那道**存储 seam**——控件只认 `StorageAdapter` 接口，存进浏览器还是 S3 与 UI 无关。

<iframe src="/skills/dashboard/assets/demo-file-upload.html"
        style="width:100%;height:620px;border:1px solid #8884;border-radius:10px"
        loading="lazy" title="形状 demo：文件上传"></iframe>

试这几下：

- 拖几张图片进虚线框（或点击选择，可多选）—— 进度条推进，完成后出缩略图；PDF/zip 等出类型图标 + 大小
- 传一个超过 2 MB 的文件或不支持的类型 —— adapter 拒绝，红条一闪 + 错误 toast，绝不静默
- 点行尾 ✕ 移除文件 —— 先过确认弹窗，移除后 toast
- 看每行下面的 `data:` URL：这就是 adapter「存储」后返回的地址，换真存储时它变成 S3 链接，其余不动

## 规矩

- **控件完全受控于 `StoredFile[]`**（`{ url, name, size }`），value/onChange 一进一出；上传中的 pending 与进度是控件内部的瞬态状态，不进外层
- **存储只经一个 seam**：`StorageAdapter = { upload(file) → Promise<StoredFile> }`。demo 用零配置的 data-URL adapter（FileReader 在浏览器里读成 `data:` URL，无后端）；真实版换成走服务端 fn 写 S3/磁盘的 adapter，浏览器这半不变
- **校验住在 adapter 后面**：demo 在 adapter 里查类型/大小、不过就 reject；真实版这段在 server fn 里（`requireUser()` + 校验 + 写桶，密钥不出服务端）
- **adapter 负责 reject，控件负责上报**：失败必出错误 toast（或用 `onError` 路由到表单错误区），绝不静默吞掉
- 样式只用主题令牌，亮暗自动；移除是破坏性操作，先过确认

> 蓝本：`add-file-upload.md`[^add-file-upload]（open-dashboard @ `aa9815f`，MIT，Invariants 已消化进上面「规矩」）

??? abstract "demo 源码：`assets/demo-file-upload.html`（自包含、未压缩）"

    ```html
    --8<-- "skills/dashboard/assets/demo-file-upload.html"
    ```

[^add-file-upload]: [`add-file-upload.md`](https://github.com/ahpxex/open-dashboard/blob/aa9815f426e9520841c4aa15a341bfd9f866091f/.claude/skills/add-component/references/add-file-upload.md)，2026-07-28 读。
