1. 优化视觉效果、后端规范化
This commit is contained in:
290
vite-tailwindcss/src/composables/resizeImage.js
Normal file
290
vite-tailwindcss/src/composables/resizeImage.js
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user