名称: bknd文件上传 描述: 使用Bknd存储上传文件时使用。涵盖MediaApi SDK方法(上传、上传到实体)、REST端点、与文件输入的React集成、使用XHR的进度跟踪、浏览器上传模式以及实体字段附件。
文件上传
使用MediaApi SDK或REST端点上传文件到Bknd存储。
先决条件
- 在Bknd配置中启用媒体模块
- 配置存储适配器(S3、R2、Cloudinary或本地)
- 安装
bknd包 - 对于实体上传:目标实体已定义
media()字段
何时使用UI模式
- 管理面板 > 媒体部分 > 拖放上传
- 快速测试存储配置
- 无需代码的一次性文件上传
何时使用代码模式
- 从前端程序化上传
- 带进度跟踪的上传
- 将文件附加到实体记录
- 自定义上传UI组件
逐步指南:UI方法
步骤1:打开管理面板
导航到http://localhost:7654(或您的Bknd URL)。
步骤2:转到媒体部分
点击侧边栏中的“媒体”以访问文件管理。
步骤3:上传文件
- 将文件拖放到上传区域,或
- 点击“上传”按钮并选择文件
步骤4:验证上传
文件出现在列表中,显示名称、大小、类型和日期。
逐步指南:代码方法
基本文件上传
import { Api } from "bknd";
const api = new Api({ host: "http://localhost:7654" });
// 从文件对象(浏览器)
const input = document.querySelector('input[type="file"]');
const file = input.files[0];
const { ok, data, error } = await api.media.upload(file);
if (ok) {
console.log("已上传:", data.name);
console.log("大小:", data.meta.size);
console.log("类型:", data.meta.type);
}
使用自定义文件名上传
const { ok, data } = await api.media.upload(file, {
filename: "custom-name.png",
});
从URL上传
// 直接URL字符串
const { ok, data } = await api.media.upload("https://example.com/image.png");
// 或从fetch响应
const response = await fetch("https://example.com/image.png");
const { ok, data } = await api.media.upload(response);
上传到实体字段
直接将文件附加到实体记录:
// 假设“posts”实体有一个名为“cover_image”的media()字段
const { ok, data } = await api.media.uploadToEntity(
"posts", // 实体名称
123, // 记录ID
"cover_image", // 字段名称
file,
{ overwrite: true } // 替换现有文件
);
if (ok) {
console.log("文件已附加:", data.result.cover_image);
}
React集成
简单文件输入
function FileUpload({ onUploaded }) {
const { api } = useApp(); // 或您的Api实例
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
const { ok, data, error } = await api.media.upload(file);
setUploading(false);
if (ok) {
onUploaded(data);
} else {
setError(error?.message || "上传失败");
}
};
return (
<div>
<input
type="file"
onChange={handleChange}
disabled={uploading}
/>
{uploading && <span>上传中...</span>}
{error && <span style={{ color: "red" }}>{error}</span>}
</div>
);
}
带预览的图像上传
function ImageUpload({ value, onChange }) {
const { api } = useApp();
const [preview, setPreview] = useState(value);
const [uploading, setUploading] = useState(false);
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// 立即显示本地预览
const localUrl = URL.createObjectURL(file);
setPreview(localUrl);
setUploading(true);
const { ok, data } = await api.media.upload(file);
setUploading(false);
if (ok) {
// 清理本地预览
URL.revokeObjectURL(localUrl);
// 使用服务器URL
onChange(data.name);
}
};
return (
<div>
{preview && (
<img
src={preview}
alt="预览"
style={{ maxWidth: 200, opacity: uploading ? 0.5 : 1 }}
/>
)}
<input type="file" accept="image/*" onChange={handleChange} />
{uploading && <span>上传中...</span>}
</div>
);
}
带进度跟踪的上传
对于大文件,使用XHR跟踪进度:
function ProgressUpload({ onUploaded }) {
const { api } = useApp();
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const uploadWithProgress = (file) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const url = api.media.getFileUploadUrl({ path: file.name });
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));
}
});
xhr.addEventListener("error", () => reject(new Error("上传失败")));
xhr.open("POST", url);
xhr.setRequestHeader("Content-Type", file.type);
// 如果使用头部传输,添加认证
const token = api.getAuthState().token;
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
}
xhr.send(file);
});
};
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setProgress(0);
try {
const result = await uploadWithProgress(file);
onUploaded(result);
} catch (err) {
console.error("上传失败:", err);
} finally {
setUploading(false);
}
};
return (
<div>
<input type="file" onChange={handleChange} disabled={uploading} />
{uploading && (
<div>
<progress value={progress} max={100} />
<span>{progress}%</span>
</div>
)}
</div>
);
}
头像上传组件
完整的头像上传,带实体附件:
function AvatarUpload({ userId, currentAvatar }) {
const { api } = useApp();
const [preview, setPreview] = useState(currentAvatar);
const [uploading, setUploading] = useState(false);
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证图像
if (!file.type.startsWith("image/")) {
alert("请选择图像文件");
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("图像必须小于5MB");
return;
}
setPreview(URL.createObjectURL(file));
setUploading(true);
const { ok, data } = await api.media.uploadToEntity(
"users",
userId,
"avatar",
file,
{ overwrite: true }
);
setUploading(false);
if (ok) {
setPreview(data.result.avatar);
}
};
return (
<div>
<img
src={preview || "/default-avatar.png"}
alt="头像"
style={{
width: 100,
height: 100,
borderRadius: "50%",
opacity: uploading ? 0.5 : 1
}}
/>
<label>
<input
type="file"
accept="image/*"
onChange={handleChange}
disabled={uploading}
style={{ display: "none" }}
/>
<button type="button" disabled={uploading}>
{uploading ? "上传中..." : "更换头像"}
</button>
</label>
</div>
);
}
REST API
上传端点
# 基本上传(路径中的文件名)
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/png" \
--data-binary @image.png \
http://localhost:7654/api/media/upload/image.png
# 上传到实体字段
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg \
"http://localhost:7654/api/media/entity/users/123/avatar?overwrite=true"
响应格式
{
"name": "image.png",
"meta": {
"type": "image/png",
"size": 24680,
"width": 800,
"height": 600
},
"etag": "abc123...",
"state": {
"name": "image.png",
"path": "image.png"
}
}
实体媒体字段
定义媒体字段以将文件链接到记录:
import { em, entity, text, media } from "bknd";
const schema = em({
posts: entity("posts", {
title: text(),
cover_image: media(), // 存储文件引用
}),
users: entity("users", {
name: text(),
avatar: media(),
}),
});
媒体字段存储文件名,而非文件内容。
服务器端上传
在Bknd服务器上下文中上传文件(种子、流):
// 在种子函数中
export default defineConfig({
options: {
seed: async (ctx) => {
const media = ctx.server?.modules.media;
if (!media) return;
// 从URL上传
const response = await fetch("https://example.com/default-avatar.png");
const buffer = await response.arrayBuffer();
await media.adapter.putObject(
"default-avatar.png",
new Uint8Array(buffer)
);
},
},
});
处理上传响应
const { ok, data, error } = await api.media.upload(file);
if (!ok) {
// 处理错误
if (error?.status === 413) {
console.error("文件过大");
} else if (error?.status === 401) {
console.error("未认证");
} else if (error?.status === 403) {
console.error("无上传权限");
} else {
console.error("上传失败:", error?.message);
}
return;
}
// 成功 - 使用数据
console.log("文件名:", data.name);
console.log("MIME类型:", data.meta.type);
console.log("大小(字节):", data.meta.size);
// 对于图像
if (data.meta.width && data.meta.height) {
console.log("尺寸:", data.meta.width, "x", data.meta.height);
}
常见陷阱
文件过大(413错误)
问题: 上传失败,状态码413。
修复: 在配置中增加body_max_size:
export default defineConfig({
media: {
enabled: true,
body_max_size: 50 * 1024 * 1024, // 50MB
adapter: { ... },
},
});
缺少Content-Type头
问题: 文件以错误的MIME类型上传。
修复: 在REST上传中始终设置Content-Type:
# 错误 - 无内容类型
curl -X POST --data-binary @image.png .../upload/image.png
# 正确
curl -X POST \
-H "Content-Type: image/png" \
--data-binary @image.png \
.../upload/image.png
SDK从文件对象自动处理此问题。
CORS错误
问题: 浏览器阻止上传到S3。
修复: 在存储桶上配置CORS(非Bknd):
{
"CORSRules": [{
"AllowedOrigins": ["https://yourapp.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"]
}]
}
XHR上传中缺少认证令牌
问题: 使用XHR进度上传时出现401错误。
修复: 添加Authorization头:
const token = api.getAuthState().token;
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
}
未定义媒体字段
问题: uploadToEntity失败,提示“字段未找到”。
修复: 确保实体有media()字段:
// 错误 - 无媒体字段
posts: entity("posts", {
title: text(),
cover_image: text(), // 这只是一个字符串
}),
// 正确
posts: entity("posts", {
title: text(),
cover_image: media(), // 正确的媒体字段
}),
大文件内存问题
问题: 浏览器在上传大文件时崩溃。
修复: 使用流式或分块上传:
// 对于非常大的文件,考虑分块上传
// Bknd没有原生分块功能,所以使用S3预签名URL
// 或实现自定义分块端点
验证
测试上传是否正确工作:
async function testUpload() {
// 1. 检查媒体是否启用
const { ok: listOk } = await api.media.listFiles();
console.log("媒体模块启用:", listOk);
// 2. 测试文件上传
const testFile = new File(["test"], "test.txt", { type: "text/plain" });
const { ok, data, error } = await api.media.upload(testFile);
console.log("上传结果:", ok ? data.name : error);
// 3. 验证文件是否存在
const { data: files } = await api.media.listFiles();
const exists = files.some(f => f.key === "test.txt");
console.log("文件在列表中:", exists);
// 4. 清理
if (exists) {
await api.media.deleteFile("test.txt");
console.log("测试文件已删除");
}
}
建议与禁忌
建议:
- 上传前验证文件类型/大小
- 对于大文件显示上传进度
- 处理所有错误情况(401、403、413)
- 使用
uploadToEntity进行实体附件 - 上传完成后清理对象URL
- 设置适当的
body_max_size限制
禁忌:
- 在需要权限时未认证上传
- 在REST上传中忘记Content-Type头
- 存储敏感文件而无访问控制
- 在生产中允许无限文件大小
- 在生产中使用本地适配器
相关技能
- bknd存储配置 - 配置存储后端
- bknd服务文件 - 提供上传的文件
- bknd添加字段 - 向实体添加媒体字段
- bkndCRUD更新 - 使用文件引用更新记录
- bknd分配权限 - 设置media.create权限