From fa6fb80d192838645c3e0d207732377cbf3714b7 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 5 Aug 2026 22:48:34 +0800 Subject: [PATCH] feat(upload): compress images over 10MB before OSS upload (v3.4.13) Co-authored-by: Cursor --- apps/admin-web/package.json | 1 + apps/admin-web/src/components/OssUpload.tsx | 9 -- apps/admin-web/src/lib/upload.ts | 17 +++- .../src/components/OssUploadField.tsx | 6 -- apps/h5-partner/src/lib/upload.ts | 17 +++- apps/h5-shop/src/lib/upload.ts | 11 ++- apps/h5-user/src/lib/upload.ts | 14 ++- apps/mini-user/src/lib/compress-image.ts | 34 +++++++ apps/mini-user/src/lib/mini-wechat-profile.ts | 5 +- packages/shared-ui/package.json | 3 +- packages/shared-ui/src/compressImage.ts | 90 +++++++++++++++++++ pnpm-lock.yaml | 3 + 杜康好客-v3-PRD.md | 2 +- 杜康好客-v3-现状对照.md | 1 + 杜康好客-v3.4.13-体验优化开发文档.md | 8 +- 15 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 apps/mini-user/src/lib/compress-image.ts create mode 100644 packages/shared-ui/src/compressImage.ts diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 4c86b6a..d66910d 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ant-design/icons": "^5.5.1", "@dukang/shared-types": "workspace:*", + "@dukang/shared-ui": "workspace:*", "antd": "^5.22.0", "dayjs": "^1.11.13", "echarts": "^6.1.0", diff --git a/apps/admin-web/src/components/OssUpload.tsx b/apps/admin-web/src/components/OssUpload.tsx index 363d1f3..160768a 100644 --- a/apps/admin-web/src/components/OssUpload.tsx +++ b/apps/admin-web/src/components/OssUpload.tsx @@ -15,8 +15,6 @@ type OssUploadProps = { placeholder?: string; }; -const DEFAULT_MAX_MB = 10; - export default function OssUpload({ value, onChange, @@ -24,7 +22,6 @@ export default function OssUpload({ bizType, mediaType = 'IMAGE', accept, - maxSizeMb = DEFAULT_MAX_MB, placeholder = '上传后自动填入,或手动粘贴 URL', }: OssUploadProps) { const [uploading, setUploading] = useState(false); @@ -34,12 +31,6 @@ export default function OssUpload({ const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const raw = file as File; - if (raw.size > maxSizeMb * 1024 * 1024) { - const err = new Error(`文件不能超过 ${maxSizeMb}MB`); - message.error(err.message); - onError?.(err); - return; - } setUploading(true); try { const result = await uploadFileToOss(raw, { bizType, mediaType }); diff --git a/apps/admin-web/src/lib/upload.ts b/apps/admin-web/src/lib/upload.ts index 5cf1c05..106e019 100644 --- a/apps/admin-web/src/lib/upload.ts +++ b/apps/admin-web/src/lib/upload.ts @@ -1,4 +1,9 @@ import { apiBase, CLIENT_APP, getToken, clearAuth } from './api'; +import { + compressImageFileIfNeeded, + DEFAULT_OSS_MAX_UPLOAD_BYTES, + formatOssMaxSizeMb, +} from '@dukang/shared-ui/compressImage'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; @@ -9,14 +14,24 @@ export type UploadFileResult = { mock: boolean; }; +async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise { + if (mediaType !== 'IMAGE') return file; + const compressed = await compressImageFileIfNeeded(file); + if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { + throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); + } + return compressed; +} + /** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ export async function uploadFileToOss( file: File, options: { bizType: string; mediaType?: OssMediaType }, ): Promise { const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); + const prepared = await prepareUploadFile(file, mediaType); const formData = new FormData(); - formData.append('file', file); + formData.append('file', prepared); formData.append('bizType', options.bizType); formData.append('mediaType', mediaType); diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index b47b639..76e7bb8 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -19,8 +19,6 @@ type OssUploadFieldProps = { onWechatReadyChange?: (ready: boolean) => void; }; -const DEFAULT_MAX_MB = 10; - function isCancelError(msg: string): boolean { return /cancel|取消/i.test(msg); } @@ -79,10 +77,6 @@ export default function OssUploadField({ showUploadError('图片读取失败,请重新选择'); return; } - if (file.size > DEFAULT_MAX_MB * 1024 * 1024) { - showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`); - return; - } const result = await uploadFileToOss(file, { bizType, mediaType }); onChange?.(result.url); } diff --git a/apps/h5-partner/src/lib/upload.ts b/apps/h5-partner/src/lib/upload.ts index 59ea2c2..8b6b1df 100644 --- a/apps/h5-partner/src/lib/upload.ts +++ b/apps/h5-partner/src/lib/upload.ts @@ -1,4 +1,9 @@ import { apiBase, request } from './api'; +import { + compressImageFileIfNeeded, + DEFAULT_OSS_MAX_UPLOAD_BYTES, + formatOssMaxSizeMb, +} from '@dukang/shared-ui/compressImage'; const UPLOAD_TIMEOUT_MS = 120_000; @@ -11,13 +16,23 @@ export type UploadFileResult = { mock: boolean; }; +async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise { + if (mediaType !== 'IMAGE') return file; + const compressed = await compressImageFileIfNeeded(file); + if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { + throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); + } + return compressed; +} + async function uploadFileToOssInner( file: File, options: { bizType: string; mediaType?: OssMediaType }, ): Promise { const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); + const prepared = await prepareUploadFile(file, mediaType); const formData = new FormData(); - formData.append('file', file); + formData.append('file', prepared); formData.append('bizType', options.bizType); formData.append('mediaType', mediaType); diff --git a/apps/h5-shop/src/lib/upload.ts b/apps/h5-shop/src/lib/upload.ts index 43adbc4..ad9b626 100644 --- a/apps/h5-shop/src/lib/upload.ts +++ b/apps/h5-shop/src/lib/upload.ts @@ -1,5 +1,10 @@ import { apiBase } from './api'; import { getStoreProfile } from './api'; +import { + compressImageFileIfNeeded, + DEFAULT_OSS_MAX_UPLOAD_BYTES, + formatOssMaxSizeMb, +} from '@dukang/shared-ui/compressImage'; const UPLOAD_TIMEOUT_MS = 120_000; @@ -16,8 +21,12 @@ export type RegisteredResource = { }; export async function uploadFileToOss(file: File, bizType: string): Promise { + const compressed = await compressImageFileIfNeeded(file); + if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { + throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); + } const formData = new FormData(); - formData.append('file', file); + formData.append('file', compressed); formData.append('bizType', bizType); formData.append('mediaType', 'IMAGE'); diff --git a/apps/h5-user/src/lib/upload.ts b/apps/h5-user/src/lib/upload.ts index dd28fb9..ba9f276 100644 --- a/apps/h5-user/src/lib/upload.ts +++ b/apps/h5-user/src/lib/upload.ts @@ -1,4 +1,9 @@ import { apiBase } from './api'; +import { + compressImageFileIfNeeded, + DEFAULT_OSS_MAX_UPLOAD_BYTES, + formatOssMaxSizeMb, +} from '@dukang/shared-ui/compressImage'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; @@ -15,8 +20,15 @@ export async function uploadFileToOss( options: { bizType: string; mediaType?: OssMediaType }, ): Promise { const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); + let prepared = file; + if (mediaType === 'IMAGE') { + prepared = await compressImageFileIfNeeded(file); + if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { + throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); + } + } const formData = new FormData(); - formData.append('file', file); + formData.append('file', prepared); formData.append('bizType', options.bizType); formData.append('mediaType', mediaType); diff --git a/apps/mini-user/src/lib/compress-image.ts b/apps/mini-user/src/lib/compress-image.ts new file mode 100644 index 0000000..78f4c55 --- /dev/null +++ b/apps/mini-user/src/lib/compress-image.ts @@ -0,0 +1,34 @@ +import Taro from '@tarojs/taro'; + +const MAX_BYTES = 10 * 1024 * 1024; + +/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */ +export async function compressWeappImageIfNeeded(tempFilePath: string): Promise { + if (process.env.TARO_ENV !== 'weapp') return tempFilePath; + + let path = tempFilePath; + let size = await getFileSize(path); + if (size <= MAX_BYTES) return path; + + const qualities = [80, 65, 50, 40]; + for (const quality of qualities) { + const res = await Taro.compressImage({ src: path, quality }); + path = res.tempFilePath; + size = await getFileSize(path); + if (size <= MAX_BYTES) return path; + } + + if (size > MAX_BYTES) { + throw new Error('图片压缩后仍超过 10MB,请换一张较小的图片'); + } + return path; +} + +async function getFileSize(filePath: string): Promise { + try { + const info = await Taro.getFileInfo({ filePath }); + return typeof info.size === 'number' ? info.size : 0; + } catch { + return 0; + } +} diff --git a/apps/mini-user/src/lib/mini-wechat-profile.ts b/apps/mini-user/src/lib/mini-wechat-profile.ts index 3ccbbdf..08c03a8 100644 --- a/apps/mini-user/src/lib/mini-wechat-profile.ts +++ b/apps/mini-user/src/lib/mini-wechat-profile.ts @@ -69,12 +69,15 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile { /** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */ export async function uploadAvatarTempFile(tempFilePath: string): Promise { const { API_BASE, CLIENT_APP, getToken } = await import('./api'); + const { compressWeappImageIfNeeded } = await import('./compress-image'); const token = getToken(); if (!token) throw new Error('请先登录'); + const filePath = await compressWeappImageIfNeeded(tempFilePath); + const res = await Taro.uploadFile({ url: `${API_BASE}/common/resources/upload`, - filePath: tempFilePath, + filePath, name: 'file', formData: { bizType: 'AVATAR', diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 698dc7b..d7ebd0f 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -11,7 +11,8 @@ "./CouponBadge": "./src/CouponBadge.tsx", "./OrderStatusTabs": "./src/OrderStatusTabs.tsx", "./AppImage": "./src/AppImage.tsx", - "./PullToRefresh": "./src/PullToRefresh.tsx" + "./PullToRefresh": "./src/PullToRefresh.tsx", + "./compressImage": "./src/compressImage.ts" }, "peerDependencies": { "react": "^18.3.1" diff --git a/packages/shared-ui/src/compressImage.ts b/packages/shared-ui/src/compressImage.ts new file mode 100644 index 0000000..8b29bc8 --- /dev/null +++ b/packages/shared-ui/src/compressImage.ts @@ -0,0 +1,90 @@ +/** OSS 单文件上限(与后端 OSS_MAX_UPLOAD_BYTES 默认一致) */ +export const DEFAULT_OSS_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +function isCompressibleImage(file: File): boolean { + if (!file.type.startsWith('image/')) return false; + if (file.type === 'image/svg+xml' || file.type === 'image/gif') return false; + return true; +} + +function loadImage(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('图片读取失败')); + }; + img.src = url; + }); +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('图片压缩失败'))), + type, + quality, + ); + }); +} + +async function renderScaled( + img: HTMLImageElement, + maxEdge: number, + quality: number, + mime: string, +): Promise { + const w = img.naturalWidth; + const h = img.naturalHeight; + const scale = Math.min(1, maxEdge / Math.max(w, h)); + const cw = Math.max(1, Math.round(w * scale)); + const ch = Math.max(1, Math.round(h * scale)); + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('图片压缩失败'); + ctx.drawImage(img, 0, 0, cw, ch); + return canvasToBlob(canvas, mime, quality); +} + +/** + * 超过 maxBytes 的图片用 canvas 压缩后再上传;不可压缩或非图片原样返回。 + */ +export async function compressImageFileIfNeeded( + file: File, + maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES, +): Promise { + if (!isCompressibleImage(file) || file.size <= maxBytes) return file; + + const img = await loadImage(file); + const mime = file.type === 'image/png' || file.type === 'image/webp' ? 'image/jpeg' : file.type || 'image/jpeg'; + const edges = [4096, 3072, 2048, 1536, 1280, 1024, 800]; + let best: Blob | null = null; + + outer: for (const edge of edges) { + for (let quality = 0.92; quality >= 0.5; quality -= 0.08) { + const blob = await renderScaled(img, edge, quality, mime); + if (!best || blob.size < best.size) best = blob; + if (blob.size <= maxBytes) { + best = blob; + break outer; + } + } + } + + if (!best || best.size >= file.size) return file; + + const ext = mime === 'image/png' ? '.png' : '.jpg'; + const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([best], `${baseName}${ext}`, { type: mime, lastModified: Date.now() }); +} + +export function formatOssMaxSizeMb(maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES): number { + return Math.floor(maxBytes / 1024 / 1024); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b9d701..07b6153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@dukang/shared-types': specifier: workspace:* version: link:../../packages/shared-types + '@dukang/shared-ui': + specifier: workspace:* + version: link:../../packages/shared-ui antd: specifier: ^5.22.0 version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index d650004..93486b9 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -48,7 +48,7 @@ | 版本 | 日期 | 说明 | |------|------|------| -| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/物流体验、H5 登录校验、合伙人微信暂停禁登;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) | +| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/物流体验、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) | --- diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index 08aa901..fa10ccf 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -342,6 +342,7 @@ C2~C7、C14 见 §1.3。 | mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | | mini-user 版本/物流 | ✅ | minClientVersion + UpdateManager;order-logistics | +| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | | 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 | --- diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md index 2f342fa..37b691a 100644 --- a/杜康好客-v3.4.13-体验优化开发文档.md +++ b/杜康好客-v3.4.13-体验优化开发文档.md @@ -7,9 +7,9 @@ | 模块 | 内容 | |------|------| -| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级 | -| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;物流追踪页 | -| h5-partner / h5-shop | 登录 phone/code 前端校验 | +| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** | +| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;物流追踪页;**头像超 10MB 压缩** | +| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** | | 后端 | 工单 priority 字段;client-config minClientVersion;合伙人微信登录暂停拦截 | ## 2. ST 映射 @@ -35,6 +35,7 @@ | ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 | | ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview | | ST1785939375449985 | 订单物流追踪页 | ✅ order-logistics | +| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload | ## 3. 后端 API / 配置 @@ -61,4 +62,5 @@ - [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 - [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 - [ ] mini-user:低版本弹窗、UpdateManager、物流追踪页 +- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 - [ ] `pnpm lint` 无新增错误