文件上传(拖拽选择 + 存储 seam)
表单和详情页绕不开的形状:拖拽或点击选文件,图片出缩略图、其他文件出类型图标,可逐个移除。要点不在控件而在背后那道存储 seam——控件只认 StorageAdapter 接口,存进浏览器还是 S3 与 UI 无关。
试这几下:
- 拖几张图片进虚线框(或点击选择,可多选)—— 进度条推进,完成后出缩略图;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(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-file-upload.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:文件上传</title>
<!--
demo-file-upload.html —— 「文件上传」形状的最小可玩实现(蓝本:open-dashboard 的 FileUpload 控件 + StorageAdapter 存储 seam,add-file-upload)。
复现的不变量:
- 控件完全受控于 StoredFile[]({ url, name, size });存储只经 StorageAdapter 接口(upload(file) → Promise<StoredFile>)到达
- 默认 adapter 是零配置的浏览器 data-URL 存储(FileReader 读成 data: URL,无后端);真实版换 S3/磁盘 adapter,浏览器这半不变
- 类型/大小校验住在 adapter 后面(真实版在 server fn:requireUser() + 校验 + 写桶):adapter 负责 reject,控件负责上报,失败必 toast,绝不静默吞掉
- 破坏性操作(移除文件)先过确认弹窗
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.drop { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 24px 16px; border: 1.5px dashed var(--border); border-radius: 10px;
background: var(--card); color: var(--muted); cursor: pointer; font: inherit; }
.drop:hover, .drop.on { border-color: var(--primary); color: var(--fg); }
.drop strong { color: var(--primary); font-weight: 600; }
.files { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.fitem { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
.thumb { width: 40px; height: 40px; border-radius: 8px; object-fit: cover;
border: 1px solid var(--border); flex: none; }
.ficon { width: 40px; height: 40px; border-radius: 8px; background: var(--chip-off-bg);
color: var(--chip-off-fg); display: grid; place-items: center;
font-size: 10px; font-weight: 700; letter-spacing: .4px; flex: none; }
.fmeta { flex: 1; min-width: 0; }
.fname { font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.furl { color: var(--muted); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fstate { color: var(--muted); font-size: 12px; flex: none; font-variant-numeric: tabular-nums; }
.bar { height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: 5px; }
.bar i { display: block; height: 100%; width: 0; background: var(--primary); transition: width .12s linear; }
.fitem.fail .bar i { background: var(--danger); }
.rm { flex: none; width: 26px; height: 26px; border: 0; border-radius: 8px; background: none;
color: var(--muted); font-size: 14px; cursor: pointer; }
.rm:hover { background: var(--chip-off-bg); color: var(--danger); }
.toast.err { background: var(--danger); color: #fff; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useRef, useEffect } = React;
const html = htm.bind(React.createElement);
// ---- 存储 seam:蓝本的 StorageAdapter = { upload(file) → Promise<StoredFile> } ----
const MAX_SIZE = 2 * 1024 * 1024; // 单文件 2 MB 上限
const OK_EXT = ["pdf", "txt", "md", "csv", "json", "zip"]; // 图片之外允许的扩展名
const ACCEPT = "image/*,." + OK_EXT.join(",.");
// 零配置浏览器存储(蓝本默认 adapter):FileReader 把文件读成 data: URL,无后端。
const dataUrlStorage = {
upload(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({ url: String(reader.result || ""), name: file.name, size: file.size });
reader.onerror = () => reject(reader.error || new Error(`读取「${file.name}」失败`));
reader.readAsDataURL(file);
});
},
};
// demo 的 adapter:把「真实版由 server fn 做的事」搬进来 —— 类型/大小校验(不过就 reject),
// 再加一点模拟网络延迟让进度条可见。换成 S3/磁盘存储时只换这个对象,控件一行不动。
const demoStorage = {
upload(file) {
if (file.size > MAX_SIZE)
return Promise.reject(new Error(`「${file.name}」超过 ${formatSize(MAX_SIZE)} 上限`));
if (!typeOk(file))
return Promise.reject(new Error(`「${file.name}」类型不支持(仅图片 / ${OK_EXT.join(" / ")})`));
const latency = 600 + Math.min(1200, file.size / 2000);
return new Promise(r => setTimeout(r, latency)).then(() => dataUrlStorage.upload(file));
},
};
function typeOk(file) {
return file.type.startsWith("image/") || OK_EXT.includes(extOf(file.name));
}
function extOf(name) {
const i = name.lastIndexOf(".");
return i < 0 ? "" : name.slice(i + 1).toLowerCase();
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function isImage(stored) {
return stored.url.startsWith("data:image/") ||
/\.(png|jpe?g|gif|webp|svg|avif)$/i.test(stored.name);
}
function Confirm({ text, onOk, onCancel }) {
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="modal">
<h2>确认操作</h2>
<p style=${{ margin: 0 }}>${text}</p>
<div class="actions">
<button class="btn" onClick=${onCancel}>取消</button>
<button class="btn primary" onClick=${onOk}>确认</button>
</div>
</div>
</div>`;
}
// 上传控件:完全受控于 value(StoredFile[])/ onChange;上传中的 pending 与进度是内部瞬态状态。
// 存储只经传入的 adapter 到达;adapter reject → onError 上报(App 里接错误 toast),绝不静默。
function FileUpload({ value, onChange, onError, onRemove, storage }) {
const inputRef = useRef(null);
const seqRef = useRef(0);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState([]); // { key, name, progress, fail }
const patch = (key, fn) => setPending(p => p.map(it => (it.key === key ? fn(it) : it)));
const unlist = key => setPending(p => p.filter(it => it.key !== key));
// 单个文件:挂 pending 行,定时器推进度(模拟网络),adapter 兑现后冲到 100% 再入正式列表
function track(file) {
const key = ++seqRef.current;
setPending(p => [...p, { key, name: file.name, progress: 6, fail: false }]);
const timer = setInterval(() => {
patch(key, it => ({ ...it, progress: Math.min(92, it.progress + 4 + Math.random() * 10) }));
}, 130);
return storage.upload(file).then(
stored => new Promise(resolve => {
clearInterval(timer);
patch(key, it => ({ ...it, progress: 100 }));
setTimeout(() => { unlist(key); resolve(stored); }, 340);
}),
err => {
clearInterval(timer);
patch(key, it => ({ ...it, progress: 100, fail: true }));
setTimeout(() => unlist(key), 1100);
throw err;
},
);
}
async function ingest(fileList) {
const picked = Array.from(fileList || []);
if (picked.length === 0) return;
const settled = await Promise.allSettled(picked.map(track));
const ok = settled.filter(s => s.status === "fulfilled").map(s => s.value);
if (ok.length > 0) onChange([...value, ...ok]); // 受控:新列表交还持有方
settled.filter(s => s.status === "rejected").forEach(s => onError(s.reason));
if (inputRef.current) inputRef.current.value = "";
}
return html`
<div>
<input ref=${inputRef} type="file" multiple accept=${ACCEPT}
style=${{ display: "none" }} onChange=${e => ingest(e.target.files)} />
<button type="button" class=${"drop" + (dragging ? " on" : "")}
onClick=${() => inputRef.current && inputRef.current.click()}
onDragOver=${e => { e.preventDefault(); setDragging(true); }}
onDragLeave=${() => setDragging(false)}
onDrop=${e => { e.preventDefault(); setDragging(false); ingest(e.dataTransfer.files); }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 16V4" /><path d="m6 10 6-6 6 6" /><path d="M4 20h16" />
</svg>
<span><strong>点击选择文件</strong> 或拖拽到这里(可多选)</span>
<span style=${{ fontSize: "12px" }}>图片 / ${OK_EXT.join(" / ")},单个不超过 ${formatSize(MAX_SIZE)}</span>
</button>
${(pending.length > 0 || value.length > 0) && html`
<ul class="files">
${pending.map(p => html`
<li key=${"p" + p.key} class=${"fitem" + (p.fail ? " fail" : "")}>
<span class="ficon">${p.fail ? "✕" : "…"}</span>
<div class="fmeta">
<div class="fname">${p.name}</div>
<div class="bar"><i style=${{ width: p.progress + "%" }}></i></div>
</div>
<span class="fstate">${p.fail ? "已拒绝" : p.progress >= 100 ? "完成" : "上传中 " + Math.round(p.progress) + "%"}</span>
</li>`)}
${value.map((f, i) => html`
<li key=${f.name + "-" + f.size + "-" + i} class="fitem">
${isImage(f)
? html`<img class="thumb" src=${f.url} alt=${f.name} />`
: html`<span class="ficon">${(extOf(f.name) || "FILE").toUpperCase()}</span>`}
<div class="fmeta">
<div class="fname">${f.name}</div>
<div class="furl" title="adapter 返回的存储地址(demo 里是 data: URL)">
${f.url.slice(0, 48)}…(共 ${f.url.length} 字符)
</div>
</div>
<span class="fstate">${formatSize(f.size)}</span>
<button class="rm" aria-label=${"移除 " + f.name} onClick=${() => onRemove(f, i)}>✕</button>
</li>`)}
</ul>`}
</div>`;
}
function App() {
const [files, setFiles] = useState([]); // 受控的 StoredFile[] —— 控件之外的唯一事实
const [confirm, setConfirm] = useState(null); // { text, onOk }
const [toast, setToast] = useState(null); // { kind: "ok" | "err", text }
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2600);
return () => clearTimeout(t);
}, [toast]);
const total = files.reduce((s, f) => s + f.size, 0);
return html`
<div class="page">
<header class="head">
<div>
<h1>附件上传</h1>
<p class="sub">拖拽或点击选择 / 图片出缩略图 / 超限或类型不符会被 adapter 拒绝</p>
</div>
</header>
<${FileUpload}
value=${files}
storage=${demoStorage}
onChange=${next => {
const added = next.length - files.length;
setFiles(next);
if (added > 0) setToast({ kind: "ok", text: `已上传 ${added} 个文件` });
}}
onError=${err => setToast({ kind: "err", text: (err && err.message) || "上传失败" })}
onRemove=${(f, i) => setConfirm({
text: `移除「${f.name}」?移除后需要重新上传。`,
onOk: () => {
setFiles(fs => fs.filter((_, j) => j !== i));
setToast({ kind: "ok", text: `已移除「${f.name}」` });
setConfirm(null);
},
})}
/>
<footer class="pager">
<span>${files.length === 0
? "还没有文件 —— 拖一张图片进来试试"
: `共 ${files.length} 个文件 · 合计 ${formatSize(total)}`}</span>
<span>内存态 demo:刷新即清空</span>
</footer>
${confirm && html`<${Confirm} text=${confirm.text} onOk=${confirm.onOk} onCancel=${() => setConfirm(null)} />`}
${toast && html`<div class=${"toast" + (toast.kind === "err" ? " err" : "")}>${toast.text}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>