概览

打印电商的”用户传图”不是简单的 <input type="file">——它要解决:单张超大高清图不卡浏览器、一次几十张相册图稳上传不丢、移动端弱网续传、上传后对齐模板图片框、人脸不被裁掉、以及列表缩略图不能直连几 MB 原图。

图模工坊把它封装成一条”用户操作 → 乐观占位 → 并发直传 MinIO → 写库 → imgproxy 缩略图回显”的管线,前端三端(pf-app / pf-editor / pf-manage)结构一致。

整体管线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
用户点"上传" / 拍照 / 多选
│ ← 0ms:URL.createObjectURL 生成本地预览,列表顶部即时出现占位卡(乐观 UI)
▼
┌───────────────────────────────────────┐
│ 前端预处理 │
│ ├─ 校验格式 / 大小 │
│ ├─ 解码尺寸(一次,commit + 卡片比例复用)│
│ └─(可选)ImageCropper 手动裁剪 │
└───────────────────────────────────────┘
│ 并发队列(限 3)
▼
┌───────────────────────────────────────┐
│ uploadOssFile() │
│ ├─ GET /api/.../oss/upload-url (presign, purpose 白名单)│
│ └─ axios.put 直传 MinIO(120s 超时,1 次网络重试,AbortSignal)│
└───────────────────────────────────────┘
│ ← 成功后 optimistic insert 到列表头,用真实 URL 替换占位
▼
userUploads[instanceKey][elementKey] = { url }
│
▼ 后台队列:faceSafe 节点
POST /api/app/face/detect → 人脸框 → 存 meta,供自动填充/避脸裁剪
│
▼
Leafer 实时重绘(占位过渡 + 抗跳帧)→ 用户预览

乐观上传 + 并发队列(核心)

  • 并发上限 3:runWithConcurrencyLimit(tasks, limit)(固定 Worker 池,Promise.allSettled 语义,保序)。pf-editor 常量 DEFAULT_UPLOAD_CONCURRENCY = 3(useOssImageUploadTasks),pf-app UPLOAD_CONCURRENCY = 3(useImageUpload)。
  • 乐观占位卡:选文件的 0ms 内用 URL.createObjectURL 生成带旋转指示器的占位卡,后台静默上传,完成后原地替换为正式素材。
  • 单项容错:单张失败只标红该卡片并给重试/移除,不阻塞其它;uploadOssFile 无 HTTP 响应的网络错误自动重试 1 次(OSS_UPLOAD_NETWORK_RETRY)。
  • 竞态/取消防护:File 对象、AbortController、尺寸 promise 存非响应式 Map(taskFiles / taskControllers / taskDimensions),状态变更统一 findTask() 重查响应式数组;disposeTask() 是唯一清理路径(revoke objectURL + abort 在途请求),onScopeDispose 全量取消。用户主动取消抛 OssUploadAbortError,不弹失败 toast。
  • 进度模型:传输占 92%,保存 96%,整批提交完才 100%。

三端的 resolveOssAssetUrl 与乐观上传结构是各自一份(未抽包),pf-editor 的注释明确说要”保持同一套结构约束”。pf-manage 的 uploadOssFile 是精简版(无重试/取消)。

缩略图走 imgproxy(resolveOssAssetUrl)

三端 src/utils/oss.ts 都有 resolveOssAssetUrl(url, width):

  1. 归一化:把裸 key/文件名按前缀映射到同源 /storage/...(template-preview-→/storage/template-previews/、user-upload-/user-crop-/auto-fill-→/storage/user-uploads/、background-→/storage/backgrounds/、editor-image-→/storage/editor-images/ 等);blob:/data:/http(s):////storage/ 原样透传。
  2. 动态缩略图:if (width > 0 && !endsWith(".svg")) 追加 ?w={width}——这个 ?w= 触发 Nginx→imgproxy 生成 WebP 缩略图。SVG 从不改写。

配套约定:pf-editor 图片面板 IMAGE_THUMB_WIDTH = 240(面板缩略图与画布占位命中同一浏览器缓存条目),拖拽/选中时 warmImageUrl(url) 预热;pf-manage 订单提交明细 resolveOssAssetUrl(url, 150/400) + Naive <NImage lazy> 懒加载。

画布落图:占位 + 抗跳帧(pf-editor)

useCanvasDragDrop.ts:

  • attachImageLoadLifecycle(el, frame, thumbUrl):Leafer Image 解码前,先叠一个瞬态占位(imagePlaceholder.ts,Group zIndex 99998、hittable:false、打 isImagePlaceholder 标记)显示低清缩略图(缓存命中、零网络)或骨架填充;真实元素保留全清 url(进 sceneJson),占位从不污染持久化。
  • 抗跳帧 scheduleForceRender(frames=3):Leafer 对首次未解码的图片填充会 ignoreRender 跳一帧并重置 changed,导致画布停在预加载帧。修复是主动 forceRender() 连续几个 rAF,外加低频兜底 [120, 400, 1200]ms。
  • 占位处理 LOADED/ERROR、rAF 轮询兜底、IMAGE_LOAD_TIMEOUT = 15000,失败给非阻塞 toast + 重试。

瞬态节点统一管理

transientNodeMetadata.ts / transientNodeQuery.ts:isTransientNode(node) = isPrintGuideNode(node) || isImagePlaceholderNode(node)。当前两类瞬态节点——出血/裁切辅助线 + 图片加载占位——都被序列化/导出统一排除;新增瞬态类型只需扩 isTransientNode,无需回扫所有调用点。

手动裁剪

ImageCropper.vue 基于 Cropper.js v2(cropperjs ^2.1.1):全屏 q-dialog,触控拖动/缩放/旋转/翻转、目标宽高提示、crossorigin="anonymous"、load/error/retry 态,提交时上传裁剪后 blob。

人脸避让

模板节点带 faceSafe: true 时:pf-app 用模块级 AsyncConcurrencyQueue(FACE_DETECTION_CONCURRENCY = 3)在上传提交后后台调 faceApi.detectByImageUrl({ minScore: 0.45, maxFaces: 10 }),结果存进图库 meta(faceDetectionStatus / faces / faceModelVersion),供自动填充做人脸感知放置。后端合成时按快照 cropBox 消费;坐标是归一化 [0,1]。

容错策略

失败场景 处理
格式/大小不合规 前端拒绝并提示
直传网络错误 uploadOssFile 自动重试 1 次(无响应时),仍失败标该项、其它继续
用户取消 AbortController → OssUploadAbortError,不弹失败
face-service 不可用 退化为普通自适应裁剪,不阻塞提交
提交时素材 URL 失效 合成明细标 failReason,运营重传后重试

字体的”上传管线”

字体不归用户传:管理员在 pf-manage 上传 ttf/otf → 入 font_variants → 前端 FontFace.load() 注入 → 合成服务通过相同 URL 加载,保证三端字形一致。

下一步

站内搜索

没有找到内容!