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/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index a28c4e4..e539176 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -258,7 +258,7 @@ export default function WineryBillsPage() { 酒厂对账单 - 每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细 + T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细 {canEditWineryBank ? ( 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-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index cec8b7b..cfa2b93 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -183,7 +183,7 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ export const WINERY_SETTLEMENT_RATE = 0.3; -/** 酒厂账单出账滞后自然日(T+3:支付日 + 3 天后纳入账单) */ +/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入账单) */ export const WINERY_SETTLEMENT_LAG_DAYS = 3; export type { LogisticsSettlementMethod } from './enums'; 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/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index 03aa0f6..727f678 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service'; /** * 财务对账单定时任务(Asia/Shanghai) - * - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00) + * - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销) * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003) */ diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index db00ca3..eace40d 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -1581,13 +1581,15 @@ export class SettlementService { return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; } + // T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays) const orders = await this.prisma.order.findMany({ where: { + status: 'COMPLETED', payStatus: 'PAID', deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, - paidAt: { gte: start, lt: end }, + completedAt: { gte: start, lt: end }, }, - orderBy: { paidAt: 'asc' }, + orderBy: { completedAt: 'asc' }, }); if (orders.length === 0 && !existing) { @@ -1633,7 +1635,8 @@ export class SettlementService { deliveryType: o.deliveryType, payAmount: o.payAmount, wineryAmount: round2(Number(o.payAmount) * rate), - paidAt: o.paidAt!, + // 明细仍保留支付时间;账期窗口按 completedAt 归属 + paidAt: o.paidAt ?? o.completedAt!, })), }); } 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` 无新增错误