From 38558b0b43fa43bd99e8b717c2643be49dcda183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 3 Aug 2026 08:57:18 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E4=BC=98=E5=8C=96=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E3=80=81=E5=90=8E=E7=AB=AF=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hunliji-api/router/routers.go | 29 +- .../src/components/PhotoGallery.vue | 102 +++--- .../src/composables/resizeImage.js | 290 ++++++++++++++++++ .../src/views/admin/AdminEditor.vue | 234 +++++++++++++- vite-tailwindcss/src/views/index/index.vue | 164 +++++----- 5 files changed, 661 insertions(+), 158 deletions(-) create mode 100644 vite-tailwindcss/src/composables/resizeImage.js diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go index f21bce4..4c3ba13 100644 --- a/hunliji-api/router/routers.go +++ b/hunliji-api/router/routers.go @@ -582,6 +582,14 @@ func collectAlbumImages(configData string) []string { seen[src] = true images = append(images, src) } + // 相册灯箱优先原图;无原图时再退缩放展示图 + addPrefer := func(primary interface{}, fallback interface{}) { + if src, ok := primary.(string); ok && strings.TrimSpace(src) != "" { + add(primary) + return + } + add(fallback) + } add(raw["heroImg"]) add(raw["endImg"]) if pages, ok := raw["photoPages"].([]interface{}); ok { @@ -590,12 +598,23 @@ func collectAlbumImages(configData string) []string { if !ok { continue } - add(page["img1"]) - add(page["img2"]) - if pageImages, ok := page["images"].([]interface{}); ok { - for _, src := range pageImages { - add(src) + addPrefer(page["img1"], page["img1Resized"]) + addPrefer(page["img2"], page["img2Resized"]) + pageImages, _ := page["images"].([]interface{}) + pageResized, _ := page["imagesResized"].([]interface{}) + n := len(pageImages) + if len(pageResized) > n { + n = len(pageResized) + } + for i := 0; i < n; i++ { + var primary, fallback interface{} + if i < len(pageImages) { + primary = pageImages[i] } + if i < len(pageResized) { + fallback = pageResized[i] + } + addPrefer(primary, fallback) } } } diff --git a/vite-tailwindcss/src/components/PhotoGallery.vue b/vite-tailwindcss/src/components/PhotoGallery.vue index 7c893c8..51a9ddb 100644 --- a/vite-tailwindcss/src/components/PhotoGallery.vue +++ b/vite-tailwindcss/src/components/PhotoGallery.vue @@ -11,7 +11,7 @@
- +
@@ -32,10 +32,10 @@ :class="frameClass" > @@ -50,10 +50,10 @@ :class="frameClass" > @@ -77,10 +77,10 @@ > @@ -97,19 +97,19 @@ :class="frameClass" > - +
@@ -146,7 +146,7 @@
- +
@@ -165,14 +165,14 @@
@@ -180,7 +180,7 @@
- +
@@ -220,7 +220,7 @@ const OLFS_SETTLE_MS = 320 const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200 const OLFS_DECODE_TIMEOUT_IOS_MS = 400 -/** 安卓九宫格/一大五小:父级 activate+停滚;其余自观测不停滚 */ +/** 仅用于 OLFS 是否跳过强制 decode */ const isIOSClient = (() => { if (typeof navigator === 'undefined') return false const ua = navigator.userAgent || '' @@ -230,18 +230,16 @@ const isIOSClient = (() => { return false })() -const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small']) -/** 自观测串行:瀑布/拍立得双端;九宫格/一大五小仅 iOS(安卓走 activate) */ -const SEQUENTIAL_SELF_TYPES = new Set( - isIOSClient - ? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small'] - : ['masonry', 'polaroid'] -) +/** 双端均不停滚:近视口 IO 串行挂载 */ +const SEQUENTIAL_SELF_TYPES = new Set([ + 'masonry', + 'polaroid', + 'nine-grid', + 'one-large-five-small', +]) const props = defineProps({ page: { type: Object, required: true }, - /** 重型版式:父级停滚后设为 true 才串行挂载(安卓) */ - activate: { type: Boolean, default: false }, /** 九宫格:开启串行 v-reveal;关闭则一次展示 */ nineGridAnimate: { type: Boolean, default: false }, }) @@ -262,6 +260,16 @@ let overlapSecondTimer = null const seq = createSequentialMount() const frameClass = computed(() => borderClass(props.page.borderStyle)) +/** 展示:缩放图优先;预览:原图优先 */ +const pickDisplay = (original, resized) => resized || original || '' +const pickOriginal = (original, resized) => original || resized || '' +const displayImg1 = computed(() => pickDisplay(props.page?.img1, props.page?.img1Resized)) +const displayImg2 = computed(() => pickDisplay(props.page?.img2, props.page?.img2Resized)) +const originalImg1 = computed(() => pickOriginal(props.page?.img1, props.page?.img1Resized)) +const originalImg2 = computed(() => pickOriginal(props.page?.img2, props.page?.img2Resized)) +const displayAt = (i) => pickDisplay(props.page?.images?.[i], props.page?.imagesResized?.[i]) +const originalAt = (i) => pickOriginal(props.page?.images?.[i], props.page?.imagesResized?.[i]) + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const clearOverlapSecondTimer = () => { @@ -294,10 +302,12 @@ const emitReady = () => { emit('ready') } -/** 关闭九宫格动画时提前暖缓存 */ +/** 关闭九宫格动画时提前暖缓存(优先展示用缩放图) */ const preloadNineGridImages = () => { if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return - const urls = (props.page.images || []).filter(Boolean) + const urls = (props.page.images || []) + .map((_, i) => displayAt(i)) + .filter(Boolean) if (!urls.length) return preloadStarted = true for (const url of urls) { @@ -481,22 +491,19 @@ const bindByType = async () => { } if (SEQUENTIAL_SELF_TYPES.has(type)) { await bindMultiObserver() - } else if (props.activate && HOLD_ACTIVATE_TYPES.has(type)) { - await startSequentialMount() } } watch( - () => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type), - async (on) => { - if (!on) return - await startSequentialMount() - }, - { flush: 'post' } -) - -watch( - () => [props.page?.type, props.page?.images, props.page?.img1, props.page?.img2], + () => [ + props.page?.type, + props.page?.images, + props.page?.imagesResized, + props.page?.img1, + props.page?.img2, + props.page?.img1Resized, + props.page?.img2Resized, + ], async () => { seq.cancel() olfsToken += 1 @@ -589,7 +596,8 @@ function onScroll() { } function previewImage(src) { - if (src) emit('preview-image', src) + const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim() + if (url && url !== '[object Object]') emit('preview-image', url) } const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5] diff --git a/vite-tailwindcss/src/composables/resizeImage.js b/vite-tailwindcss/src/composables/resizeImage.js new file mode 100644 index 0000000..222485c --- /dev/null +++ b/vite-tailwindcss/src/composables/resizeImage.js @@ -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, + } +} diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 52ee6f7..30de52c 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -505,20 +505,58 @@ +
+
+ 原图用于预览;展示图为等比缩放后的体积(不裁切)。 + {{ resizeProgress }} +
+ + 一键缩放并上传 + +