Upload
upload文件上传 · 拖拽落区/按钮形态 + accept/maxSize/limit 校验 + 受控列表(缩略图/进度/拖拽调序),传输层拆成 useUpload(request 由消费者提供·并发+取消)
用法
基础用法(拖拽落区)
默认 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" | 形态:拖拽落区 / 单按钮 |
| 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 | 列表项移除按钮点击 |
| 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 | 重传单个失败项 |
reorder | 接 <Upload onSort>,只换顺序不影响在飞任务 |
clear | 全部取消并清空 |
uploading | 是否还有排队中或进行中的任务 |
禁忌 / 坑
<Upload>不会自己上传。要自动上传就配useUpload;坚持自己管,就把status/progress/error/url回填到受控files。- 不要指望库帮你解后端信封。
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 也会打进包里。
相关
Sortable · ImageCropper · SecretField · Combobox · Listbox · Progress
Playground
<Upload
variant="dropzone"
multiple
maxSize={5 * 1024 * 1024}
files={files}
onSelect={(picked) => /* 上传并回填 files */}
onRemove={(id) => /* 移除 */}
/>