Upload
upload点击或拖拽选文件,带校验、进度和文件列表
用法
基础用法(拖拽落区)
默认 dropzone 形态:点击或拖拽文件,校验通过的文件经 onSelect 抛出,状态/进度由消费者回填到 files。
<Upload
multiple
hint="支持任意格式,单文件 ≤ 5MB"
maxSize={5 * 1024 * 1024}
files={files}
onSelect={(picked) => /* 上传并回填 files */}
onRemove={(id) => /* 移除 */}
/>按钮形态
variant="button" 收成单按钮,accept 限定文件类型。
<Upload variant="button" accept="image/*" buttonLabel="上传头像" />文件列表与状态
files 受控展示:success / uploading(带进度条 + 百分比)/ error(带错误文案)三态共存。
- report-2026.pdf1.8 MB
- cover.png62%
- huge-video.mov超过 5MB 上限
const files = [
{ id: "a", name: "report-2026.pdf", size: 1.8 * 1024 * 1024, status: "success" },
{ id: "b", name: "cover.png", size: 820 * 1024, status: "uploading", progress: 62 },
{ id: "c", name: "huge-video.mov", status: "error", error: "超过 5MB 上限" },
];
<Upload variant="button" files={files} onRemove={(id) => remove(id)} />自动上传(useUpload)
传输层拆成独立 hook:request 由你提供(fetch/XHR/OSS SDK 随意),hook 只管排队、并发闸门、进度回填与取消。库内不认识 action/headers/信封形状。
const up = useUpload({
request: async (file, { onProgress, signal }) => {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd, signal });
onProgress(100);
return { url: (await res.json()).data.url }; // 信封解包在应用层
},
concurrency: 2,
});
<Upload multiple limit={5} files={up.files} onSelect={up.add} onRemove={up.remove} />数量上限 limit
达到 limit 后触发器自动禁用并显示「已选 n/limit」;本次选择中超出剩余名额的文件进 onReject(reason="limit")。
- report-2026.pdf1.8 MB
- cover.png62%
- huge-video.mov超过 5MB 上限
<Upload
multiple
limit={3}
files={files}
onSelect={add}
onRemove={remove}
onReject={(rs) => rs.some((r) => r.reason === "limit") && toast("最多 3 个")}
/>缩略图 + 拖拽调序
renderPreview 是渲染钩子(本地文件用 URL.createObjectURL(raw),历史文件用 url);sortable + onSort 打开手柄拖拽调序,顺序仍由你写回 files。
banner-1.png240.0 KB
banner-2.png310.0 KB
banner-3.png180.0 KB
<Upload
multiple
accept="image/*"
limit={6}
sortable
files={files}
onSelect={add}
onRemove={remove}
onSort={setFiles}
renderPreview={(f) => <img src={f.url ?? preview(f.raw)} alt={f.name} />}
/>禁用态
<Upload disabled hint="已禁用" />何时用
需要选/拖文件并展示带缩略图、状态、进度的文件列表时用。
分层(这是本组件的核心设计约束,别搞混):
<Upload>= 纯皮肤 + 状态展示。它仍然不做网络传输,只在通过accept/maxSize/limit校验后回onSelect(File[]),其余状态靠受控files回填。useUpload({ request, concurrency })= 传输层。队列、并发闸门、进度回填、取消、重传都在这里;但怎么发由你给的request决定。
request 的签名是 (file, { onProgress, signal }) => Promise<{ url }>。库内刻意没有 action / headers / withCredentials / transformResponse 这类参数——瑚琏是通用库,不给某一家后端的响应信封开后门,鉴权与解包请写在你的应用层闭包里。
落区形态用 variant="dropzone",紧凑场景用 variant="button"。图片选完要裁剪请配合 ImageCropper;纯排序不涉及上传用 Sortable。
导入
import { Upload, useUpload, matchesAccept, moveUploadFile } from "@hulianui/ui"Props
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
| accept | string | - | 原生 accept(如 "image/*,.pdf");同时用于落区校验 |
| multiple | boolean | false | 是否允许多选 |
| disabled | boolean | false | 禁用 |
| maxSize | number | - | 单文件字节上限;超限进 onReject(reason="size") |
| limit | number | - | 文件数量上限(按 files.length 计);达标后触发器自动禁用并显示「已选 n/limit」,超额进 onReject(reason="limit") |
| variant | "dropzone" | "button" | "dropzone" | 形态:拖拽落区 / 单按钮 |
| size | "sm" | "md" | "lg" | "md" | 尺寸档:落区高度 / button 形态的按钮高度(button 形态与 <Button> 同名档等高) |
| name | string | - | 内层 <input type="file"> 的 name。给了它才是真表单控件:原生 <form> + new FormData(form) 读得到文件,required 才会被浏览器校验 |
| required | boolean | - | 原生必填校验,透传到内层 input(需同时给 `name`);同时给触发器挂上 sr-only 的「必填」说明 |
| aria-required | boolean | "true" | "false" | - | 必填的语义标记,通常由 <Field required> 自动注入,不用自己传。只开无障碍那一半(触发器上的 sr-only「必填」说明),不打开原生 required 校验 |
| inputRef | Ref<HTMLInputElement> | - | 拿到内层 input 的引用(自定义校验、手动清空、第三方表单库注册) |
| resetInputAfterSelect | boolean | 无 name 时 true;有 name 时 false | 选完是否清空 input.value。清了才能重复选同一个文件,清了 FormData 就读不到 |
| files | UploadFile[] | - | 受控展示的文件列表(含状态/进度/url);不传则不渲染列表 |
| renderPreview | (file: UploadFile) => ReactNode | - | 缩略图渲染钩子;返回节点时列表项左侧变 40px 预览位(状态点降级为角标),返回 null 回落默认圆点 |
| sortable | boolean | false | 列表可拖拽调序(需同时传 `onSort` 才生效) |
| className | string | - | 容器类名 |
Events
| 事件 | 类型 | 说明 |
|---|---|---|
| onSelect | (files: File[]) => void | 通过校验的文件被选中(点击选择或拖入) |
| onReject | (rejections: UploadRejection[]) => void | 被校验拒绝的文件(reason: "type" / "size" / "limit") |
| onRemove | (id: string) => void | 列表项移除按钮点击 |
| onRetry | (id: string) => void | 失败行重试按钮点击。传了才渲染这个按钮(同 onRemove 的口径);直接接 useUpload 的 retry |
| onSort | (files: UploadFile[]) => void | 拖拽调序后的新顺序(组件不偷存顺序,由你写回 files) |
Slots
| 插槽 | 类型 | 说明 |
|---|---|---|
| label | ReactNode | 落区主文案 |
| hint | ReactNode | 落区辅助说明(格式/大小限制提示) |
| buttonLabel | ReactNode | button 形态的按钮文案(默认 "选择文件") |
| children | ReactNode | 自定义落区内容(覆盖 label/hint) |
UploadFile:{ id; name; size?; status?: "ready"\|"uploading"\|"success"\|"error"; progress?; error?; url?; raw? }·progress仅status="uploading"时展示进度条 + 百分比(内部 clamp 到 0-100) ·url/raw都是纯附加字段,不参与组件内部逻辑,只供renderPreview与你自己回读;onSelect仍然给File[],File 语义没被替换
useUpload(传输层)
const up = useUpload({ request, concurrency?, onChange?, onSuccess?, onError? })
// up: { files, add, remove, retry, reorder, clear, uploading }| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
| request | (file, { onProgress, signal }) => Promise<{ url }> | - | 必填。怎么发由你定;signal 请透传给 fetch/XHR |
| concurrency | number | 3 | 并发上限,超出的排队(0 会兜到 1,不会死锁) |
| onChange | (files: UploadFile[]) => void | - | 任一次 files 变化后回调 |
| onSuccess / onError | (file, result | error) => void | - | 单个文件落定回调(被 abort 的取消不触发 onError) |
| 返回 | 说明 |
|---|---|
files | 直接喂 <Upload files> |
add | 接 <Upload onSelect>,入列并按并发自动开传 |
remove | 接 <Upload onRemove>,进行中的任务会被 abort |
retry | 接 <Upload onRetry>,重传单个失败项 |
reorder | 接 <Upload onSort>,只换顺序不影响在飞任务 |
clear | 全部取消并清空 |
uploading | 是否还有排队中或进行中的任务 |
禁忌 / 坑
<Upload>不会自己上传。要自动上传就配useUpload;坚持自己管,就把status/progress/error/url回填到受控files。- 不给 `name` 就没有原生表单这条路:没有 name 的 input 压根不出现在
FormData的 entries 里,required也不会被浏览器校验。这两件事都不会报错,只是静默不生效。 - 挂了
name后有三处默认行为跟着变(都只在有 name 时发生):① 选完不再清value(否则 FormData 永远读到空),代价是同一个文件连选两次不会再触发 `onSelect`——要那个行为就显式写resetInputAfterSelect,但那样 FormData 就读不到了,二者不可兼得;② 拖入的文件会被写回input.files(原生拖放不会自己进去),同时被 accept/maxSize/limit 拒掉的文件会被剔出去,避免「界面说拒了、表单照样提交」;③ 达到limit时 input 不跟着禁用——禁用控件会被 FormData 整个跳过,已选的文件会在提交时凭空消失(触发器那侧照旧禁用,点不开选择框)。 - 写回
input.files依赖DataTransfer构造器。测试环境(jsdom)没有它,回写会静默跳过,onSelect那条路不受影响——别在 jsdom 里断言拖入后的 `FormData` 内容。 - 失败行的重试按钮要传 `onRetry` 才出现。
useUpload早就有retry,但组件不替你决定「这个失败该不该给重试入口」——直接onRetry={up.retry}接上即可。 - 不要指望库帮你解后端信封。
request拿到的{ url }是你自己 resolve 的,code/data/msg之类的形状在你的闭包里剥完再返回。 sortable单独给不生效,必须同时给 `onSort`(顺序是受控的,组件不偷存);只给sortable会静默退回静态列表。renderPreview每次渲染都会被调用,别在里面直接 `URL.createObjectURL` —— 会漏对象 URL。缓存到Map<id, url>并在卸载时revokeObjectURL(showcase 的useObjectUrls是可抄的写法)。limit按受控files.length算;files没传时视为 0,此时limit只能拦住"单次选太多",拦不住累计。useUpload的remove会abort(),但只有你把 `signal` 透传下去才真取消;迟到的 resolve 会被丢弃,不会复活已移除的行。- 引入
sortable让 upload 静态依赖了@dnd-kit/*(与 Sortable/Kanban 同源),source 分发下不用 sortable 也会打进包里。 - 必填对辅助技术的表达走说明文本,不是
aria-required(#294):落区是role="button"、button 形态是真<button>,而aria-required在 ARIA 里只对输入型 role 有效,挂上去读屏不会念。传required(或把 Upload 放进<Field required>)时,触发器会串上一段 sr-only 的「必填」说明。 <Field required>只给语义标记,不打开原生required校验 —— 要浏览器拦下提交,仍需自己传required且同时给name。- 落区自己的
aria-describedby串的是hint与必填说明,Field 的description/error不走这条路:说明文案请用组件的hint。
相关
Sortable · ImageCropper · SecretField · Combobox · Listbox · Progress
Playground
<Upload
variant="dropzone"
multiple
maxSize={5 * 1024 * 1024}
files={files}
onSelect={(picked) => /* 上传并回填 files */}
onRemove={(id) => /* 移除 */}
/>