青梧 UI

问题:默认值是陷阱

Upload 的默认配置面向多图相册场景:一张图默认产出 original + webp + avif 三份(formats)、允许多选 (multiple: true)、数量不限(maxCount: 0)。而封面是单值字段(如 coverUrl: string)——三个默认值一个都不满足,必须显式覆盖:

默认值后果封面场景覆盖
formats: ["original","webp","avif"]一张封面 = 3 个上传项 = 3 次上传 = 3 份存储 = 3 个 URLformats: ["webp"] —— 只产一份
maxCount: 0(不限)无上限,单值字段装不下多张maxCount: 1
multiple: true一次多选,列表撑爆multiple: false

注意:maxCount: 1 满额后再选新图是拒绝,不是替换 (validateFile 直接返回 "count")——换图由宿主完成(见下文)。

单文件模式的容器行为maxCount: 1 时拖拽容器本身承载大图预览—— 成功/上传中显示图片,悬停显示「点击移除」(右上角 ✕ 为一键清空:该图衍生的 全部格式项、回显项与上传中的请求一并清除,即 clear() 语义),列表同步隐藏; URL 导入入口位于图片框内底部(大图态隐藏,恢复默认后重现);上传过程有 350ms 视觉保底,快速上传不会一闪而过。

接入配置

trigger: "button" 形态:不占版面、不与页面其他拖拽区抢事件,同时关闭 URL 面板 ——URL 的「外链原样入库」语义由你自己的 URL 输入框承担,Upload 的 URL 导入是 「下载外链 → 压缩 → 重传到你的存储」的资源搬移语义,两者天差地别,别混。

import { ImageUpload } from "@qingwu-ui/upload";
import "@qingwu-ui/upload/style.css";

// 单值字段:一张封面只留一个 URL,默认值三连覆盖
const uploader = new ImageUpload(el, {
  trigger: "button",        // 小按钮形态,不与页面其他拖拽区抢事件
  formats: ["webp"],        // 只产一份 → 一个上传项 → 一个 URL
  maxCount: 1,              // 最多一张(注意:满额后再选会被拒绝,不是替换)
  multiple: false,
  urlImport: false,         // 关闭 URL 面板:外链语义由你自己的 URL 输入框承担
  uploadFn: uploadCover,    // 宿主 XHR:拿真进度 + 拿存储 URL
});

URL 生命周期:宿主的 map

两个事实决定 URL 必须由宿主持有:

  • UploadItem 没有「上传结果 URL」字段(originalUrl 是 URL 导入的源地址,不是产物地址)
  • 内置 XHR 上传不解析响应体——load 只检查状态码,宿主拿不到存储 URL。 所以封面场景必须用 uploadFn,宿主自己拿 URL

uploadFn 签名是 Promise<void>,拿不到 item.id——用File 引用做 key 把 URL 交给 onSuccess(传给 uploadFn 的item.file 是同一引用)。

// uploadFn 签名:Promise<void>,存储 URL 用 File 引用做 key 交给 onSuccess
const urlByFile = new Map<File, string>();

const uploadCover: UploadFn = (file, onProgress) =>
  new Promise((resolve, reject) => {
    const form = new FormData();
    form.set("file", file);
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/editor-assets");
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    });
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          const body = JSON.parse(xhr.responseText);
          const url = body?.data?.url ?? body?.url;
          if (url) { urlByFile.set(file, url); resolve(); }
          else reject(new Error("资源服务未返回访问地址"));
        } catch {
          reject(new Error("资源服务响应解析失败"));
        }
      } else reject(new Error(`上传失败:HTTP ${xhr.status}`));
    };
    xhr.onerror = () => reject(new Error("网络错误,上传失败"));
    xhr.send(form);
  });

删除链路:用户点删除 → 组件 remove(id) 同步触发 onChange(只删列表项,不碰存储)→ 宿主拿全量 items 与自己的 map 做差集 → 被移除的 id 对应 URL → 调存储删除接口。存储删除是宿主的职责,组件不做。

const urlById = new Map<string, string>(); // item.id -> 本站路径(待删除资产)

uploader.onChange = (items) => {
  // 组件 remove(id) 只删列表项,存储删除是宿主的职责:
  // onChange 是全量列表,与 urlById 做差集即可。
  // 注意:单文件容器 ✕ 是 clear() 一键全清——onChange 一次收到空数组,差集自然全删
  const ids = new Set(items.map((i) => i.id));
  for (const [id, url] of urlById) {
    if (ids.has(id)) continue;
    urlById.delete(id);
    if (url.startsWith("/")) void removeAsset(url); // 只删本站资产,外链不碰
    if (currentFieldValue === url) onChangeField(""); // 被删的正是当前封面
  }
};

uploader.onSuccess = (item) => {
  const fullUrl = urlByFile.get(item.file);
  if (!fullUrl) return;
  const pathname = new URL(fullUrl).pathname; // 转相对路径存字段(规避 next/image remotePatterns)
  const prev = priorCoverRef.current;         // onStart 时记录的上一次字段值
  urlById.set(item.id, pathname);
  if (prev && prev !== pathname && prev.startsWith("/")) {
    void removeAsset(prev);                   // 换图:新图成功后再删旧图,失败则不删
  }
  onChangeField(pathname);
};

换图与编辑态

编辑已有封面时,用 initialUrlscoverUrl 回显为成功项 (缩略图 + 已上传 + 删除按钮),无需组件外另挂预览。回显项计入数量上限——maxCount: 1 时换图需先删旧项(列表 ✕ → onChange 差集删存储 + 清字段)再选新图。

// 编辑态:initialUrls 把已有 coverUrl 回显为成功项(缩略图 + 已上传 + 删除)
new ImageUpload(el, {
  initialUrls: existingCoverUrl ? [existingCoverUrl] : [], // 编辑态回显
  // 回显项渲染为成功态,不参与上传;删除走 remove → onChange 差集(宿主删存储 + 清字段)
});

换图删除时机:新图成功后再删旧图。先删的代价不可逆——新图上传失败 → 旧图存储已删、 字段还显示旧图 → 发布即裂图。后删的唯一代价是孤儿图(上传成功但未发布就关页),那是存量问题。 且只删本站资产(相对路径 / 开头)——手动填的外链 URL 绝不碰。

提交校验联动

上传是异步的:上传中 coverUrl 还是空,用户手快点提交 → 校验器报「封面必填」。 不要在校验器里感知上传状态,直接提交按钮联动上传状态

// 上传中 coverUrl 仍为空,提交按钮必须联动上传状态
<button disabled={isLast && (submitting || uploading)}>发布</button>
// 上传失败时 uploading=false、coverUrl 为空 → 校验器正常报「封面必填」+ 展示失败原因

上传中用户仍可填写其他字段,只有提交被禁;上传失败 → 状态复位 → 校验器正常报错 + 失败原因展示。