Bknd文件上传Skill bknd-file-upload

本技能介绍如何使用Bknd平台进行文件上传,包括通过UI和代码两种方式,使用MediaApi SDK或REST API,支持React集成、进度跟踪、实体字段附件等。适用于前端和后端开发,涉及低代码开发场景。关键词:Bknd、文件上传、SDK、REST API、React、进度跟踪、实体媒体字段。

低代码开发 0 次安装 0 次浏览 更新于 3/8/2026

名称: 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权限