feat(distribution-banner): 分销站点轮播支持 PC/移动端分图与热区配置
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 图片透明区域探测:用于后台在上传「热区按钮图 / 海报底图」时提示
|
||||
* 「这张图其实没有透明区域」——最常见的坑是把设计工具里带棋盘格的预览
|
||||
* 截图/另存下来,棋盘格就成了真实像素,前台会照实显示。
|
||||
*/
|
||||
|
||||
export interface ImageTransparencyProbe {
|
||||
/** 是否存在透明像素(alpha < 250) */
|
||||
hasTransparentPixels: boolean
|
||||
/** 透明像素占比(0-1) */
|
||||
transparentRatio: number
|
||||
/** 不透明像素是否只有极少数浅灰/白颜色(棋盘格或纯色底烘焙进图片的典型特征) */
|
||||
suspectedBakedBackdrop: boolean
|
||||
}
|
||||
|
||||
const SAMPLE_MAX_SIDE = 160
|
||||
/** alpha 低于该值即视为透明像素 */
|
||||
const TRANSPARENT_ALPHA = 250
|
||||
/** 无明显透明像素的判定阈值(透明像素占比) */
|
||||
const NEGLIGIBLE_TRANSPARENT_RATIO = 0.002
|
||||
|
||||
/** 纯像素分析(无 DOM 依赖,便于单测)。 */
|
||||
export function analyzeImagePixels(data: Uint8ClampedArray | number[]): ImageTransparencyProbe {
|
||||
const total = Math.floor(data.length / 4)
|
||||
if (total <= 0) {
|
||||
return { hasTransparentPixels: false, transparentRatio: 0, suspectedBakedBackdrop: false }
|
||||
}
|
||||
|
||||
let transparent = 0
|
||||
let opaque = 0
|
||||
let lightOnly = true
|
||||
const colors = new Set<number>()
|
||||
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const offset = index * 4
|
||||
const r = data[offset] ?? 0
|
||||
const g = data[offset + 1] ?? 0
|
||||
const b = data[offset + 2] ?? 0
|
||||
const a = data[offset + 3] ?? 0
|
||||
if (a < TRANSPARENT_ALPHA) {
|
||||
transparent += 1
|
||||
continue
|
||||
}
|
||||
opaque += 1
|
||||
const min = Math.min(r, g, b)
|
||||
const max = Math.max(r, g, b)
|
||||
if (min < 216 || max - min > 28) lightOnly = false
|
||||
// 量化到 4bit/通道,避免抗锯齿噪点导致颜色数虚高
|
||||
colors.add(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4))
|
||||
}
|
||||
|
||||
const transparentRatio = transparent / total
|
||||
return {
|
||||
hasTransparentPixels: transparentRatio > NEGLIGIBLE_TRANSPARENT_RATIO,
|
||||
transparentRatio,
|
||||
suspectedBakedBackdrop:
|
||||
opaque > 0 &&
|
||||
lightOnly &&
|
||||
transparentRatio <= NEGLIGIBLE_TRANSPARENT_RATIO &&
|
||||
colors.size <= 4,
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取本地文件像素并分析;环境不支持时返回 null(不阻塞上传)。 */
|
||||
export async function probeImageTransparency(file: File): Promise<ImageTransparencyProbe | null> {
|
||||
const source = await loadImageSource(file)
|
||||
if (!source) return null
|
||||
const width = Math.max(1, Math.round(source.width))
|
||||
const height = Math.max(1, Math.round(source.height))
|
||||
const scale = Math.min(1, SAMPLE_MAX_SIDE / Math.max(width, height))
|
||||
const targetWidth = Math.max(1, Math.round(width * scale))
|
||||
const targetHeight = Math.max(1, Math.round(height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return null
|
||||
context.clearRect(0, 0, targetWidth, targetHeight)
|
||||
context.drawImage(source as CanvasImageSource, 0, 0, targetWidth, targetHeight)
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight)
|
||||
return analyzeImagePixels(imageData.data)
|
||||
}
|
||||
|
||||
async function loadImageSource(file: File): Promise<ImageBitmap | HTMLImageElement | null> {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
return await createImageBitmap(file)
|
||||
} catch {
|
||||
// 继续走 <img> 兜底
|
||||
}
|
||||
}
|
||||
return await new Promise(resolve => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(null)
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { probeImageTransparency } from './imageTransparency'
|
||||
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1280,
|
||||
announcement: 1600,
|
||||
'home-banner': 1920,
|
||||
'aw-recycle': 1920,
|
||||
'cooperation-feedback': 1920,
|
||||
listing: 1920,
|
||||
dispute: 1920,
|
||||
handoff: 1920,
|
||||
realname: 1920,
|
||||
qrcode: 2560,
|
||||
}
|
||||
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
avatar: 0.82,
|
||||
chat: 0.8,
|
||||
announcement: 0.84,
|
||||
'home-banner': 0.84,
|
||||
'aw-recycle': 0.84,
|
||||
'cooperation-feedback': 0.84,
|
||||
listing: 0.84,
|
||||
dispute: 0.86,
|
||||
handoff: 0.86,
|
||||
realname: 0.86,
|
||||
qrcode: 0.95,
|
||||
}
|
||||
|
||||
export interface UploadEncoding {
|
||||
maxSide: number
|
||||
quality: number
|
||||
outputType: 'image/webp' | 'image/png'
|
||||
extension: 'webp' | 'png'
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定上传前的压缩参数。
|
||||
*
|
||||
* 关键点:带透明通道的图(例如热区透明按钮图、透明 Logo)必须输出 PNG,
|
||||
* 否则压缩过程会把透明区域压成实色(历史上就是先铺白底再编码,导致透明按钮图变白底方块)。
|
||||
*/
|
||||
export function resolveUploadEncoding(scene: string, preserveAlpha: boolean): UploadEncoding {
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
|
||||
return {
|
||||
maxSide,
|
||||
quality,
|
||||
outputType: preserveAlpha ? 'image/png' : 'image/webp',
|
||||
extension: preserveAlpha ? 'png' : 'webp',
|
||||
}
|
||||
}
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
|
||||
if (typeof document === 'undefined') return file
|
||||
|
||||
try {
|
||||
// 先判断原图是否真的有透明像素:有则全程保留 alpha
|
||||
const probe = await probeImageTransparency(file)
|
||||
const preserveAlpha = Boolean(probe?.hasTransparentPixels)
|
||||
const { maxSide, quality, outputType, extension } = resolveUploadEncoding(scene, preserveAlpha)
|
||||
|
||||
const image = await loadImage(file)
|
||||
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
// 不铺白底:canvas 本身保留透明,输出 PNG / WebP 都能带 alpha
|
||||
context.clearRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
const blob = await canvasToBlob(canvas, outputType, quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, extension), {
|
||||
type: outputType,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function fitSize(width: number, height: number, maxSide: number) {
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
||||
return new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
return `${base || 'image'}.${ext}`
|
||||
}
|
||||
Reference in New Issue
Block a user