This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<File> {
|
||||
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<UploadFileResult> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function WineryBillsPage() {
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<File> {
|
||||
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<UploadFileResult> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<UploadFileResult> {
|
||||
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');
|
||||
|
||||
|
||||
@@ -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<UploadFileResult> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */
|
||||
export async function compressWeappImageIfNeeded(tempFilePath: string): Promise<string> {
|
||||
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<number> {
|
||||
try {
|
||||
const info = await Taro.getFileInfo({ filePath });
|
||||
return typeof info.size === 'number' ? info.size : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -69,12 +69,15 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
||||
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
||||
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
||||
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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<HTMLImageElement> {
|
||||
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<Blob> {
|
||||
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<Blob> {
|
||||
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<File> {
|
||||
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);
|
||||
}
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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!,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 开发文档 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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` 无新增错误
|
||||
|
||||
Reference in New Issue
Block a user