安装
pnpm dlx shadcn@latest add https://yiui.dev/r/file-upload.json用法
"use client"
import { useState } from "react"
import {
FileUpload,
appendFiles,
type FileUploadItem,
} from "@/components/ui/file-upload/file-upload"
export function ApplicationAttachments() {
const [items, setItems] = useState<FileUploadItem[]>([])
return (
<form
onSubmit={async (event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
appendFiles(data, "attachments", items)
await fetch("/api/application", { method: "POST", body: data })
}}
>
<FileUpload
value={items}
onValueChange={setItems}
accept="image/*,.pdf"
maxSize={10 * 1024 * 1024}
maxFiles={5}
/>
<button type="submit">提交</button>
</form>
)
}只选择,提交时才上传
组件不负责上传,不发送请求。value / defaultValue / onValueChange 管理文件列表。提交时调用 appendFiles(formData, name, items),再由业务代码发送请求。槽位的值是单个条目或 null。
默认状态为 pending(待提交);使用方把状态改为 uploading、error、done。progress 是 0–100 的数字,上传中显示百分比和进度条;error 是该文件的错误信息。传入 onRetry 后,失败的条目右侧会出现重试按钮,点击时把该条目交回给你重新上传。
证件取景相机
FileUploadSlot 默认 viewfinder="id-card"。触屏设备(pointer: coarse)、支持 getUserMedia 且处于安全上下文时,点击空槽或重拍会打开页内相机,使用后置摄像头和证件取景框。拍摄按画面中的取景框裁成 JPEG,确认「使用这张」后写入槽位,状态为 pending。
正式环境需要 HTTPS;localhost 可用于开发。局域网 HTTP 会退回带 capture 的系统相机;桌面仍然打开文件选择。权限被拒或相机出错时关闭对话框、退回系统相机,并显示可通过 labels.cameraFallback 覆盖的提示。viewfinder={false} 始终使用原生输入。相册按钮使用不带 capture 的输入。
槽位支持 uploading 的遮罩、进度条和百分比,error 的红框及错误文字,done 的「已上传」徽标。传入 onRetry(item) 可重传失败槽位,按钮读屏名称包含槽位标签。上传中禁止重拍和删除。关闭、卸载、确认照片或切到后台会释放相机轨道。
校验与清除错误
accept 支持 MIME、通配 MIME 和扩展名(如 image/*,.pdf)。maxSize 的单位为字节,maxFiles 限制总数。multiple={false} 最多保留一个文件。非法文件不进入列表,通过 onReject 返回 type、size、count 原因。下一次成功选择或删除会清除旧错误;同一次选择仍有拒绝文件时保留本次原因。外部 error 由使用方更新。
为什么不同步到原生 input
iOS Safari 对通过脚本写入 FileList 的支持不可靠。因此组件不提供隐式表单文件提交:每次处理后清空 file input,允许重复选择同一文件;提交必须显式调用 appendFiles。表单 reset 时,非受控组件回到 defaultValue;受控值由使用方重置。
示例
证件正反面
用 FileUploadSlot 配合内置线稿,窄容器单列展示。两面选好才能提交;国徽面首次固定失败,可单独重试。
提交与单独重试
用定时器逐个模拟上传,首次提交的第一个文件固定失败,点它右侧的重试按钮(onRetry)单独重传。示例不发送真实请求。
手机上的体验
手机外框为 402 × 874。触屏使用拍照、相册按钮;桌面使用拖放区。capture={false} 隐藏拍照按钮,只接受非图片时也隐藏。
API
MIME 或扩展名列表,逗号分隔。@default undefined
类型string | undefined单文件大小上限,字节。@default Infinity
类型number | undefined手机拍照方向;false 隐藏拍照。@default "environment"
类型"environment" | "user" | false | undefined整体错误提示。
类型React.ReactNodeboolean | undefinedfalsePartial<{ cameraAlign: string; cameraLight: string; cameraConfirm: string; cameraCancel: string; cameraShoot: string; cameraUse: string; cameraFallback: string; uploaded: string; drop: string; choose: string; camera: string; album: string; pending: string; uploading: string; error: string; done: string; remove: string; retry: string; retryShort: string; replace: string; delete: string; upload: string; any: string; hint: string; unlimited: string; typeError: string; sizeError: string; countError: string; }> | undefined((rejections: FileUploadRejection[]) => void) | undefinedFileUploadItem[] | undefinedFileUploadItem[] | undefined[]((value: FileUploadItem[]) => void) | undefined总文件数上限。@default Infinity
类型number | undefinedboolean | undefinedtrue传入后,上传失败的条目右侧出现「重试」按钮;重新上传由使用方完成
类型((item: FileUploadItem) => void) | undefinedFileUploadSlot
MIME 或扩展名列表,逗号分隔。@default undefined
类型string | undefined单文件大小上限,字节。@default Infinity
类型number | undefined手机拍照方向;false 隐藏拍照。@default "environment"
类型"environment" | "user" | false | undefined整体错误提示。
类型React.ReactNodeboolean | undefinedfalsePartial<{ cameraAlign: string; cameraLight: string; cameraConfirm: string; cameraCancel: string; cameraShoot: string; cameraUse: string; cameraFallback: string; uploaded: string; drop: string; choose: string; camera: string; album: string; pending: string; uploading: string; error: string; done: string; remove: string; retry: string; retryShort: string; replace: string; delete: string; upload: string; any: string; hint: string; unlimited: string; typeError: string; sizeError: string; countError: string; }> | undefined((rejections: FileUploadRejection[]) => void) | undefined触屏安全上下文中启用证件取景;false 始终走原生输入。@default "id-card"
类型"id-card" | false | undefined点击失败槽位的重试按钮,由使用方更新上传状态。
类型((item: FileUploadItem) => void) | undefinedstringReact.ReactNodeFileUploadItem | null | undefinedFileUploadItem | null | undefinednull((value: FileUploadItem | null) => void) | undefinedlabel 为必填槽位名称;placeholder 可放 ReactNode 示意图;默认 accept="image/*"、capture="environment",单文件选择会替换旧值。
类型与工具
type FileUploadItem = {
id: string
file: File
status?: "pending" | "uploading" | "error" | "done"
progress?: number
error?: string
}
type FileUploadRejection = { file: File; reason: "type" | "size" | "count" }
appendFiles(formData: FormData, name: string, items: FileUploadItem[]): voidIdCardFrontIllustration、IdCardBackIllustration 接受 SVG 属性,使用 currentColor,仅为示意图。FileUploadLabels 导出全部文案类型;两种组件均接受 labels?: Partial<FileUploadLabels>。相机文案还可覆盖 cameraAlign(含 {label})、cameraLight、cameraConfirm、cameraCancel、cameraShoot、cameraUse、cameraFallback 和 uploaded。可覆盖 drop、choose、camera、album、四种状态、remove、retry、replace、delete、upload、any、hint、unlimited 和三个错误模板。模板占位符见安装源码的 config.ts。
data-slot
| 元素 | data-slot |
|---|---|
| 多文件根节点 | file-upload |
| 普通 / 拍照输入 | file-upload-input / file-upload-camera |
| 拖放区 | file-upload-dropzone |
| 缩略图 | file-upload-preview |
| 错误区 | file-upload-error |
| 槽位根节点 / 控件 | file-upload-slot / file-upload-slot-control |
| 槽位输入 / 触发按钮 | file-upload-slot-input / file-upload-slot-trigger |
| 相机对话框 | file-upload-camera-dialog |
| 槽位错误区 | file-upload-slot-error |
文件列表复用官方 Attachment 的 attachment-* 槽位。拖入状态用 data-dragging 表示。
可访问性
相机使用 Base UI Dialog 管理焦点:打开后焦点限制在对话框内,Esc 关闭;圆形快门按钮读屏名称为「拍摄」。
选择按钮及空槽使用真正的 button,Tab 聚焦后按 Enter 或空格打开文件选择。删除、重试按钮的名称包含文件名。错误区使用 aria-live="polite",控件通过 aria-describedby 关联错误提示。被拒绝的文件只在下方给出一行说明,不把整个控件标红;红色边框跟随外部传入的 error 或条目的 error 状态。
注意事项
- 文件格式和大小也必须由服务器验证;
accept只是前端筛选。 - HEIC 不做转码,是否接收由
accept决定。图片预览失败时显示扩展名。 - objectURL 在 effect 中创建,替换、删除和卸载时释放。
- 手机拍照行为由浏览器和系统决定,
capture是提示。 - 相同文件允许重复添加;需要去重时在受控回调中处理。