Merge commit '7fa69c2521eefbfc1b78cca0e8a45264291c967d' into dev_jacy
CI / verify (pull_request) Waiting to run
CI / verify (pull_request) Waiting to run
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Image, Input, Modal, Space, Typography, Upload, message } from 'antd';
|
||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||
@@ -13,6 +13,7 @@ type OssUploadProps = {
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
placeholder?: string;
|
||||
uploadHint?: ReactNode;
|
||||
};
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
@@ -23,6 +24,32 @@ function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function formatUploadImageMessage(result: UploadFileResult): string {
|
||||
const img = result.image;
|
||||
if (!img) return '上传成功';
|
||||
const sizeLabel = `${img.width}×${img.height}`;
|
||||
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||
if (original !== sizeLabel) {
|
||||
return `上传成功(${sizeLabel},已由 ${original} 自动压缩)`;
|
||||
}
|
||||
}
|
||||
return `上传成功(${sizeLabel})`;
|
||||
}
|
||||
|
||||
function formatUploadImageHint(result: UploadFileResult): string | null {
|
||||
const img = result.image;
|
||||
if (!img) return null;
|
||||
const sizeLabel = `${img.width}×${img.height}`;
|
||||
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||
if (original !== sizeLabel) {
|
||||
return `${sizeLabel}(已由 ${original} 自动压缩)`;
|
||||
}
|
||||
}
|
||||
return sizeLabel;
|
||||
}
|
||||
|
||||
export default function OssUpload({
|
||||
value,
|
||||
onChange,
|
||||
@@ -31,9 +58,11 @@ export default function OssUpload({
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||
uploadHint,
|
||||
}: OssUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||
const [imageMetaHint, setImageMetaHint] = useState<string | null>(null);
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||
@@ -45,7 +74,9 @@ export default function OssUpload({
|
||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
onUploaded?.(result);
|
||||
message.success('上传成功');
|
||||
const successMessage = formatUploadImageMessage(result);
|
||||
setImageMetaHint(formatUploadImageHint(result));
|
||||
message.success(successMessage);
|
||||
onSuccess?.(result);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败');
|
||||
@@ -116,6 +147,16 @@ export default function OssUpload({
|
||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||
)}
|
||||
{filePreview}
|
||||
{uploadHint ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{uploadHint}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{imageMetaHint ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前底图:{imageMetaHint}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Space wrap>
|
||||
<Upload
|
||||
accept={resolvedAccept}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
import type { OssUploadImageMeta } from '@dukang/shared-types';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@@ -12,6 +13,7 @@ export type UploadFileResult = {
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
image?: OssUploadImageMeta;
|
||||
};
|
||||
|
||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ACTIVITY_POSTER_STATUS_LABELS,
|
||||
ACTIVITY_POSTER_UPLOAD_HINT,
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||
type ActivityPosterItem,
|
||||
type ActivityPosterQrSlot,
|
||||
@@ -280,7 +281,7 @@ export default function ActivityPostersPage() {
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
||||
<OssUpload bizType="ACTIVITY_POSTER" />
|
||||
<OssUpload bizType="ACTIVITY_POSTER" uploadHint={ACTIVITY_POSTER_UPLOAD_HINT} />
|
||||
</Form.Item>
|
||||
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
|
||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
|
||||
## 1. 版本交付
|
||||
|
||||
@@ -36,4 +36,4 @@
|
||||
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
||||
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
||||
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期 |
|
||||
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
|
||||
|
||||
@@ -77,3 +77,38 @@
|
||||
- [ ] 客服无合伙人/门店/核销图
|
||||
- [ ] 待办卡与超管发版区不变
|
||||
- [ ] domain 折线单测通过
|
||||
|
||||
---
|
||||
|
||||
## 6. 活动图上传压缩(DPT-20260902-849)
|
||||
|
||||
### 6.1 目标
|
||||
|
||||
HQ 上传活动底图时,后端用 Sharp 自动等比缩放至 v4.0.7 底图限制(最长边 ≤ 2500px、宽×高 ≤ 400 万),并在响应与表单中提示尺寸。
|
||||
|
||||
### 6.2 规则
|
||||
|
||||
- 入口:`POST /common/resources/upload`,`bizType=ACTIVITY_POSTER`、`mediaType=IMAGE`
|
||||
- 尺寸合规:原样存 OSS,响应 `image.compressed=false`
|
||||
- 尺寸超限:Sharp 等比缩放后存 OSS,响应含 `width/height/originalWidth/originalHeight/compressed`
|
||||
- 有 alpha 通道保留 PNG;否则按原 mime 或 JPEG quality 85 输出
|
||||
- 压缩后仍超过 `OSS_MAX_UPLOAD_BYTES` → 4xx,文案含当前尺寸
|
||||
- **手动粘贴 URL** 仍不经上传压缩;合成时沿用 v4.0.7 校验
|
||||
|
||||
### 6.3 变更面
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| shared-types | `activity-poster.ts`:`activityPosterResizeTarget`、`OssUploadImageMeta`、`ACTIVITY_POSTER_UPLOAD_HINT` |
|
||||
| API | `common/image/activity-poster-upload.util.ts`、`resource.service.ts` |
|
||||
| HQ | `OssUpload.tsx`、`ActivityPostersPage.tsx`、`lib/upload.ts` |
|
||||
|
||||
无 Prisma 迁移。
|
||||
|
||||
### 6.4 验收
|
||||
|
||||
- [ ] 上传 1080×1920:成功,提示尺寸,未标记压缩
|
||||
- [ ] 上传 4000×3000:成功,提示压缩后尺寸与原尺寸
|
||||
- [ ] 保存后「为该合伙人下载」/ zip 导出不再报底图过大
|
||||
- [ ] 非图片 / 损坏文件:明确 4xx
|
||||
- [ ] AVATAR 等其他 bizType 行为不变
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
activityPosterPackFileName,
|
||||
activityPosterQrSlotPx,
|
||||
activityPosterResizeTarget,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from './activity-poster';
|
||||
|
||||
@@ -37,6 +38,30 @@ describe('activityPosterTemplateTooLarge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('activityPosterResizeTarget', () => {
|
||||
it('returns null for 1080x1920', () => {
|
||||
expect(activityPosterResizeTarget(1080, 1920)).toBeNull();
|
||||
});
|
||||
|
||||
it('scales edge over 2500', () => {
|
||||
const target = activityPosterResizeTarget(4000, 3000);
|
||||
expect(target).not.toBeNull();
|
||||
expect(Math.max(target!.width, target!.height)).toBeLessThanOrEqual(2500);
|
||||
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
|
||||
});
|
||||
|
||||
it('scales pixel count over 4e6', () => {
|
||||
const target = activityPosterResizeTarget(2500, 2000);
|
||||
expect(target).not.toBeNull();
|
||||
expect(target!.width * target!.height).toBeLessThanOrEqual(4_000_000);
|
||||
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not upscale small images', () => {
|
||||
expect(activityPosterResizeTarget(100, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activityPosterPackFileName', () => {
|
||||
it('uses city, company, id', () => {
|
||||
expect(activityPosterPackFileName({
|
||||
|
||||
@@ -72,6 +72,39 @@ export function activityPosterTemplateTooLarge(width: number, height: number): b
|
||||
return w > ACTIVITY_POSTER_MAX_EDGE || h > ACTIVITY_POSTER_MAX_EDGE || w * h > ACTIVITY_POSTER_MAX_PIXELS;
|
||||
}
|
||||
|
||||
/** HQ 上传活动图时的尺寸说明(前后端共用文案) */
|
||||
export const ACTIVITY_POSTER_UPLOAD_HINT =
|
||||
'底图最长边不超过 2500px,总像素不超过 400 万;超出将自动压缩';
|
||||
|
||||
export type OssUploadImageMeta = {
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
compressed: boolean;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
/** 若底图超限,返回等比缩放后的目标宽高;否则 null(不放大) */
|
||||
export function activityPosterResizeTarget(
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } | null {
|
||||
const w = Math.max(1, Math.round(width));
|
||||
const h = Math.max(1, Math.round(height));
|
||||
if (!activityPosterTemplateTooLarge(w, h)) return null;
|
||||
|
||||
const maxEdge = Math.max(w, h);
|
||||
const scaleEdge = maxEdge > ACTIVITY_POSTER_MAX_EDGE ? ACTIVITY_POSTER_MAX_EDGE / maxEdge : 1;
|
||||
const scalePixels = w * h > ACTIVITY_POSTER_MAX_PIXELS ? Math.sqrt(ACTIVITY_POSTER_MAX_PIXELS / (w * h)) : 1;
|
||||
const scale = Math.min(scaleEdge, scalePixels);
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(w * scale)),
|
||||
height: Math.max(1, Math.round(h * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
/** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */
|
||||
export function activityPosterPackFileName(input: {
|
||||
cityName?: string | null;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
activityPosterResizeTarget,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from '@dukang/shared-types';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const JPEG_QUALITY = 85;
|
||||
|
||||
export type ActivityPosterUploadPrepared = {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
compressed: boolean;
|
||||
};
|
||||
|
||||
function resolveOutputMime(hasAlpha: boolean, inputMime?: string): string {
|
||||
if (hasAlpha) return 'image/png';
|
||||
if (inputMime === 'image/webp') return 'image/jpeg';
|
||||
if (inputMime?.startsWith('image/')) return inputMime;
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
async function encodeImage(
|
||||
pipeline: sharp.Sharp,
|
||||
mimeType: string,
|
||||
): Promise<Buffer> {
|
||||
if (mimeType === 'image/png') {
|
||||
return pipeline.png().toBuffer();
|
||||
}
|
||||
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
||||
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
if (mimeType === 'image/webp') {
|
||||
return pipeline.webp({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
|
||||
export async function prepareActivityPosterUpload(input: {
|
||||
buffer: Buffer;
|
||||
mimeType?: string;
|
||||
}): Promise<ActivityPosterUploadPrepared> {
|
||||
if (!input.buffer?.length) {
|
||||
throw new BadRequestException('活动图文件为空');
|
||||
}
|
||||
if (input.mimeType && !input.mimeType.startsWith('image/')) {
|
||||
throw new BadRequestException('活动图仅支持图片文件');
|
||||
}
|
||||
|
||||
const meta = await sharp(input.buffer, { failOn: 'none' }).metadata();
|
||||
if (!meta.width || !meta.height) {
|
||||
throw new BadRequestException('活动图无法读取尺寸,请换一张图片');
|
||||
}
|
||||
|
||||
const originalWidth = meta.width;
|
||||
const originalHeight = meta.height;
|
||||
const resizeTarget = activityPosterResizeTarget(originalWidth, originalHeight);
|
||||
|
||||
if (!resizeTarget) {
|
||||
return {
|
||||
buffer: input.buffer,
|
||||
mimeType: input.mimeType || 'image/jpeg',
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
compressed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hasAlpha = meta.hasAlpha === true;
|
||||
const outputMime = resolveOutputMime(hasAlpha, input.mimeType);
|
||||
const buffer = await encodeImage(
|
||||
sharp(input.buffer, { failOn: 'none' }).resize(resizeTarget.width, resizeTarget.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
}),
|
||||
outputMime,
|
||||
);
|
||||
const metaOut = await sharp(buffer).metadata();
|
||||
const width = metaOut.width ?? resizeTarget.width;
|
||||
const height = metaOut.height ?? resizeTarget.height;
|
||||
|
||||
if (activityPosterTemplateTooLarge(width, height)) {
|
||||
throw new BadRequestException('活动图压缩后仍超出尺寸限制,请换一张较小的图片');
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
mimeType: outputMime,
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
compressed: true,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@pri
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { prepareActivityPosterUpload } from '../../common/image/activity-poster-upload.util';
|
||||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
|
||||
@@ -105,19 +106,75 @@ export class ResourceService {
|
||||
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('头像仅支持图片文件');
|
||||
}
|
||||
if (dto.bizType === 'ACTIVITY_POSTER' && dto.mediaType === 'IMAGE' && !file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('活动图仅支持图片文件');
|
||||
}
|
||||
|
||||
let uploadBuffer = file.buffer;
|
||||
let uploadMimeType = file.mimetype;
|
||||
let imageMeta: {
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
compressed: boolean;
|
||||
fileSize: number;
|
||||
} | undefined;
|
||||
|
||||
if (dto.bizType === 'ACTIVITY_POSTER' && dto.mediaType === 'IMAGE') {
|
||||
const prepared = await prepareActivityPosterUpload({
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
if (prepared.buffer.length > maxUploadBytes) {
|
||||
const message = `活动图压缩后仍超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB(当前 ${prepared.width}×${prepared.height})`;
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody: {
|
||||
...requestBody,
|
||||
imageWidth: prepared.width,
|
||||
imageHeight: prepared.height,
|
||||
compressed: prepared.compressed,
|
||||
},
|
||||
status: 'FAILED',
|
||||
errorMessage: message,
|
||||
});
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
uploadBuffer = prepared.buffer;
|
||||
uploadMimeType = prepared.mimeType;
|
||||
imageMeta = {
|
||||
width: prepared.width,
|
||||
height: prepared.height,
|
||||
originalWidth: prepared.originalWidth,
|
||||
originalHeight: prepared.originalHeight,
|
||||
compressed: prepared.compressed,
|
||||
fileSize: prepared.buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.oss.putObject({
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: file.originalname || 'upload.bin',
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
buffer: uploadBuffer,
|
||||
mimeType: uploadMimeType,
|
||||
});
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody,
|
||||
requestBody: {
|
||||
...requestBody,
|
||||
...(imageMeta
|
||||
? {
|
||||
imageWidth: imageMeta.width,
|
||||
imageHeight: imageMeta.height,
|
||||
compressed: imageMeta.compressed,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
responseBody: {
|
||||
bucket: result.bucket,
|
||||
region: result.region,
|
||||
@@ -139,14 +196,14 @@ export class ResourceService {
|
||||
ossKey: result.ossKey,
|
||||
url: result.url,
|
||||
fileName: file.originalname || 'avatar',
|
||||
fileSize: BigInt(file.size),
|
||||
mimeType: file.mimetype,
|
||||
fileSize: BigInt(uploadBuffer.length),
|
||||
mimeType: uploadMimeType,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...result, resourceId: resource.id });
|
||||
return serializeBigInt({ ...result, resourceId: resource.id, ...(imageMeta ? { image: imageMeta } : {}) });
|
||||
}
|
||||
return result;
|
||||
return { ...result, ...(imageMeta ? { image: imageMeta } : {}) };
|
||||
} catch (err) {
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
|
||||
Reference in New Issue
Block a user