1. 优化视觉效果、后端规范化

This commit is contained in:
李琦
2026-08-03 08:57:18 +08:00
parent 9efc2b21d6
commit 38558b0b43
5 changed files with 661 additions and 158 deletions

View File

@@ -0,0 +1,290 @@
/**
* 画廊槽位等比缩放(不裁切):装入 maxWidth×maxHeight 边界框后导出 jpeg。
*/
const JPEG_QUALITY = 0.85
const JPEG_MIME = 'image/jpeg'
export function loadImage(url) {
return new Promise((resolve, reject) => {
if (!url || typeof url !== 'string') {
reject(new Error('图片地址无效'))
return
}
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('图片加载失败(可能跨域未开 CORS请使用本站或已配置 CORS 的图)'))
img.src = url
})
}
/**
* 等比缩小进边界框,只缩小不放大;输出尺寸可能小于 max。
* @returns {Promise<{ blob: Blob, originalWidth: number, originalHeight: number, width: number, height: number }>}
*/
export function resizeToFitBlob(img, {
maxWidth,
maxHeight,
mime = JPEG_MIME,
quality = JPEG_QUALITY,
} = {}) {
const mw = Math.max(1, Number(maxWidth) || 1)
const mh = Math.max(1, Number(maxHeight) || 1)
const sw = img.naturalWidth || img.width
const sh = img.naturalHeight || img.height
if (!sw || !sh) return Promise.reject(new Error('无法读取图片尺寸'))
const scale = Math.min(mw / sw, mh / sh, 1)
const dw = Math.max(1, Math.round(sw * scale))
const dh = Math.max(1, Math.round(sh * scale))
const canvas = document.createElement('canvas')
canvas.width = dw
canvas.height = dh
const ctx = canvas.getContext('2d')
if (!ctx) return Promise.reject(new Error('Canvas 不可用'))
ctx.drawImage(img, 0, 0, dw, dh)
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error('导出失败'))
else {
resolve({
blob,
originalWidth: sw,
originalHeight: sh,
width: dw,
height: dh,
})
}
},
mime,
quality,
)
})
}
/** 文件字节 → 可读大小 */
export function formatBytes(n) {
const bytes = Number(n)
if (!Number.isFinite(bytes) || bytes < 0) return '?'
if (bytes < 1024) return `${Math.round(bytes)} B`
if (bytes < 1024 * 1024) {
const kb = bytes / 1024
return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`
}
const mb = bytes / (1024 * 1024)
return `${mb < 10 ? mb.toFixed(2) : mb.toFixed(1)} MB`
}
/** 后台展示用:像素 + 文件大小两行对比 */
export function formatResizedMeta({
originalWidth,
originalHeight,
width,
height,
originalBytes,
resizedBytes,
}) {
const dim = `${originalWidth}×${originalHeight})→(${width}×${height}`
const file = `${formatBytes(originalBytes)})→(${formatBytes(resizedBytes)}`
return `${dim}\n${file}`
}
/** meta 是否已含文件大小(旧数据只有像素行) */
export function metaHasFileSize(meta) {
return typeof meta === 'string' && /\b(\d+(\.\d+)?\s?(B|KB|MB|GB))\b/i.test(meta)
}
/** 仅读取 natural 尺寸(不设 crossOrigin无 CORS 也能拿宽高) */
export function loadImageSize(url) {
return new Promise((resolve, reject) => {
if (!url || typeof url !== 'string') {
reject(new Error('图片地址无效'))
return
}
const img = new Image()
img.onload = () => {
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
if (!width || !height) reject(new Error('无法读取图片尺寸'))
else resolve({ width, height })
}
img.onerror = () => reject(new Error('图片加载失败'))
img.src = url
})
}
/** 读取远程文件字节数:优先 HEAD Content-Length失败再 GET */
export async function fetchFileBytes(url) {
if (!url || typeof url !== 'string') throw new Error('图片地址无效')
try {
const head = await fetch(url, { method: 'HEAD', mode: 'cors' })
if (head.ok) {
const len = head.headers.get('content-length')
if (len && Number(len) > 0) return Number(len)
}
} catch { /* fall through */ }
const res = await fetch(url, { mode: 'cors', cache: 'force-cache' })
if (!res.ok) throw new Error('读取文件大小失败')
const blob = await res.blob()
return blob.size
}
/** 根据原图/展示图 URL 探测像素+文件大小文案(用于旧数据补全 meta */
export async function probeResizedMeta(originalUrl, resizedUrl) {
const [o, r, originalBytes, resizedBytes] = await Promise.all([
loadImageSize(originalUrl),
loadImageSize(resizedUrl),
fetchFileBytes(originalUrl).catch(() => -1),
fetchFileBytes(resizedUrl).catch(() => -1),
])
return formatResizedMeta({
originalWidth: o.width,
originalHeight: o.height,
width: r.width,
height: r.height,
originalBytes,
resizedBytes,
})
}
export function blobToUploadFile(blob, name = 'resized.jpg') {
return new File([blob], name, { type: blob.type || JPEG_MIME })
}
/** 按版式返回槽位缩放规格 */
export function getSlotResizeSpecs(page) {
if (!page?.type) return []
if (page.type === 'single') {
const full = page.singleWidth === 'full'
const maxWidth = full ? 1080 : 800
const maxHeight = full ? 1350 : 1000
return [{
key: 'img1',
maxWidth,
maxHeight,
getOriginal: () => page.img1 || '',
setResized: (url, meta = '') => {
page.img1Resized = url
page.img1ResizedMeta = meta || ''
},
clearResized: () => {
page.img1Resized = ''
page.img1ResizedMeta = ''
},
hasResized: () => !!page.img1Resized,
}]
}
if (page.type === 'overlap') {
const box = { maxWidth: 900, maxHeight: 1200 }
return [
{
key: 'img1',
...box,
getOriginal: () => page.img1 || '',
setResized: (url, meta = '') => {
page.img1Resized = url
page.img1ResizedMeta = meta || ''
},
clearResized: () => {
page.img1Resized = ''
page.img1ResizedMeta = ''
},
hasResized: () => !!page.img1Resized,
},
{
key: 'img2',
...box,
getOriginal: () => page.img2 || '',
setResized: (url, meta = '') => {
page.img2Resized = url
page.img2ResizedMeta = meta || ''
},
clearResized: () => {
page.img2Resized = ''
page.img2ResizedMeta = ''
},
hasResized: () => !!page.img2Resized,
},
]
}
const list = Array.isArray(page.images) ? page.images : []
const ensureResizedArr = () => {
if (!Array.isArray(page.imagesResized)) page.imagesResized = []
if (!Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta = []
while (page.imagesResized.length < list.length) page.imagesResized.push('')
while (page.imagesResizedMeta.length < list.length) page.imagesResizedMeta.push('')
if (page.imagesResized.length > list.length) page.imagesResized.length = list.length
if (page.imagesResizedMeta.length > list.length) page.imagesResizedMeta.length = list.length
}
ensureResizedArr()
let boxForIndex = () => ({ maxWidth: 800, maxHeight: 1000 })
if (page.type === 'nine-grid') {
boxForIndex = () => ({ maxWidth: 600, maxHeight: 600 })
} else if (page.type === 'one-large-five-small') {
boxForIndex = (i) => (i === 0
? { maxWidth: 1200, maxHeight: 1200 }
: { maxWidth: 600, maxHeight: 600 })
} else if (page.type === 'masonry') {
boxForIndex = () => ({ maxWidth: 800, maxHeight: 1067 })
} else if (page.type === 'carousel') {
boxForIndex = () => ({ maxWidth: 1200, maxHeight: 780 })
} else if (page.type === 'polaroid') {
boxForIndex = () => ({ maxWidth: 800, maxHeight: 1000 })
}
return list.map((_, i) => {
const box = boxForIndex(i)
return {
key: `images[${i}]`,
...box,
getOriginal: () => page.images[i] || '',
setResized: (url, meta = '') => {
ensureResizedArr()
page.imagesResized[i] = url
page.imagesResizedMeta[i] = meta || ''
},
clearResized: () => {
ensureResizedArr()
page.imagesResized[i] = ''
page.imagesResizedMeta[i] = ''
},
hasResized: () => !!(page.imagesResized && page.imagesResized[i]),
}
})
}
export async function resizeSlotToFile(originalUrl, maxWidth, maxHeight, fileName) {
const [img, originalBytes] = await Promise.all([
loadImage(originalUrl),
fetchFileBytes(originalUrl).catch(() => -1),
])
const { blob, originalWidth, originalHeight, width, height } = await resizeToFitBlob(img, {
maxWidth,
maxHeight,
})
return {
file: blobToUploadFile(blob, fileName || 'resized.jpg'),
meta: formatResizedMeta({
originalWidth,
originalHeight,
width,
height,
originalBytes,
resizedBytes: blob.size,
}),
originalWidth,
originalHeight,
width,
height,
originalBytes,
resizedBytes: blob.size,
}
}