1. 优化视觉效果、后端规范化
This commit is contained in:
@@ -582,6 +582,14 @@ func collectAlbumImages(configData string) []string {
|
|||||||
seen[src] = true
|
seen[src] = true
|
||||||
images = append(images, src)
|
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["heroImg"])
|
||||||
add(raw["endImg"])
|
add(raw["endImg"])
|
||||||
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
||||||
@@ -590,12 +598,23 @@ func collectAlbumImages(configData string) []string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
add(page["img1"])
|
addPrefer(page["img1"], page["img1Resized"])
|
||||||
add(page["img2"])
|
addPrefer(page["img2"], page["img2Resized"])
|
||||||
if pageImages, ok := page["images"].([]interface{}); ok {
|
pageImages, _ := page["images"].([]interface{})
|
||||||
for _, src := range pageImages {
|
pageResized, _ := page["imagesResized"].([]interface{})
|
||||||
add(src)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
|
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
|
||||||
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
|
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
|
||||||
<div :class="singleFrameClass">
|
<div :class="singleFrameClass">
|
||||||
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
|
<img :src="displayImg1" class="preview-img" @click.stop="previewImage(originalImg1)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="page.img1"
|
:src="displayImg1"
|
||||||
class="preview-img overlap-img"
|
class="preview-img overlap-img"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(page.img1)"
|
@click.stop="previewImage(originalImg1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,10 +50,10 @@
|
|||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="page.img2"
|
:src="displayImg2"
|
||||||
class="preview-img overlap-img"
|
class="preview-img overlap-img"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(page.img2)"
|
@click.stop="previewImage(originalImg2)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +77,10 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref="olfsLargeImgRef"
|
ref="olfsLargeImgRef"
|
||||||
:src="page.images[0]"
|
:src="displayAt(0)"
|
||||||
class="preview-img olfs-img"
|
class="preview-img olfs-img"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(page.images[0])"
|
@click.stop="previewImage(originalAt(0))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,19 +97,19 @@
|
|||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="page.images[i]"
|
:src="displayAt(i)"
|
||||||
class="preview-img olfs-img"
|
class="preview-img olfs-img"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(page.images[i])"
|
@click.stop="previewImage(originalAt(i))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 九宫格:串行挂载 + immediate(安卓 activate;iOS 近视口 IO) -->
|
<!-- 九宫格:近视口 IO 串行挂载(双端不停滚) -->
|
||||||
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
||||||
<div
|
<div
|
||||||
v-for="(img, imgIdx) in (page.images || [])"
|
v-for="(_, imgIdx) in (page.images || [])"
|
||||||
:key="imgIdx"
|
:key="imgIdx"
|
||||||
class="aspect-square relative nine-grid-slot"
|
class="aspect-square relative nine-grid-slot"
|
||||||
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
|
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
|
||||||
@@ -121,10 +121,10 @@
|
|||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="img"
|
:src="displayAt(imgIdx)"
|
||||||
class="nine-grid-img preview-img"
|
class="nine-grid-img preview-img"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(img)"
|
@click.stop="previewImage(originalAt(imgIdx))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -133,11 +133,11 @@
|
|||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="img"
|
:src="displayAt(imgIdx)"
|
||||||
class="nine-grid-img preview-img"
|
class="nine-grid-img preview-img"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@click.stop="previewImage(img)"
|
@click.stop="previewImage(originalAt(imgIdx))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<!-- 瀑布流:固定槽位 + 串行挂载 -->
|
<!-- 瀑布流:固定槽位 + 串行挂载 -->
|
||||||
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
|
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
|
||||||
<div
|
<div
|
||||||
v-for="(img, imgIdx) in (page.images || [])"
|
v-for="(_, imgIdx) in (page.images || [])"
|
||||||
:key="imgIdx"
|
:key="imgIdx"
|
||||||
class="g-masonry-slot"
|
class="g-masonry-slot"
|
||||||
:class="{ 'g-masonry-placeholder': imgIdx >= shownCount }"
|
:class="{ 'g-masonry-placeholder': imgIdx >= shownCount }"
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
class="g-masonry-item"
|
class="g-masonry-item"
|
||||||
:class="frameClass"
|
:class="frameClass"
|
||||||
>
|
>
|
||||||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,14 +165,14 @@
|
|||||||
<!-- 轮播滑动 -->
|
<!-- 轮播滑动 -->
|
||||||
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
|
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
|
||||||
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
|
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
|
||||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
|
<div v-for="(_, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
|
||||||
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
|
<img :src="displayAt(imgIdx)" class="preview-img" @click.stop="previewImage(originalAt(imgIdx))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张">‹</button>
|
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张">‹</button>
|
||||||
<button class="g-arrow g-next" @click="goTo(index + 1)" aria-label="下一张">›</button>
|
<button class="g-arrow g-next" @click="goTo(index + 1)" aria-label="下一张">›</button>
|
||||||
<div class="g-dots">
|
<div class="g-dots">
|
||||||
<button v-for="(img, imgIdx) in page.images" :key="imgIdx"
|
<button v-for="(_, imgIdx) in page.images" :key="imgIdx"
|
||||||
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
|
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<!-- 拍立得:固定槽位 + 串行挂载 -->
|
<!-- 拍立得:固定槽位 + 串行挂载 -->
|
||||||
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
|
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
|
||||||
<div
|
<div
|
||||||
v-for="(img, imgIdx) in (page.images || [])"
|
v-for="(_, imgIdx) in (page.images || [])"
|
||||||
:key="imgIdx"
|
:key="imgIdx"
|
||||||
class="g-polaroid-slot"
|
class="g-polaroid-slot"
|
||||||
:class="{ 'g-polaroid-placeholder': imgIdx >= shownCount }"
|
:class="{ 'g-polaroid-placeholder': imgIdx >= shownCount }"
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
class="g-polaroid-item"
|
class="g-polaroid-item"
|
||||||
:style="{ transform: polaroidTransform(imgIdx) }"
|
:style="{ transform: polaroidTransform(imgIdx) }"
|
||||||
>
|
>
|
||||||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +220,7 @@ const OLFS_SETTLE_MS = 320
|
|||||||
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
|
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
|
||||||
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
|
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
|
||||||
|
|
||||||
/** 安卓九宫格/一大五小:父级 activate+停滚;其余自观测不停滚 */
|
/** 仅用于 OLFS 是否跳过强制 decode */
|
||||||
const isIOSClient = (() => {
|
const isIOSClient = (() => {
|
||||||
if (typeof navigator === 'undefined') return false
|
if (typeof navigator === 'undefined') return false
|
||||||
const ua = navigator.userAgent || ''
|
const ua = navigator.userAgent || ''
|
||||||
@@ -230,18 +230,16 @@ const isIOSClient = (() => {
|
|||||||
return false
|
return false
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
|
/** 双端均不停滚:近视口 IO 串行挂载 */
|
||||||
/** 自观测串行:瀑布/拍立得双端;九宫格/一大五小仅 iOS(安卓走 activate) */
|
const SEQUENTIAL_SELF_TYPES = new Set([
|
||||||
const SEQUENTIAL_SELF_TYPES = new Set(
|
'masonry',
|
||||||
isIOSClient
|
'polaroid',
|
||||||
? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small']
|
'nine-grid',
|
||||||
: ['masonry', 'polaroid']
|
'one-large-five-small',
|
||||||
)
|
])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
page: { type: Object, required: true },
|
page: { type: Object, required: true },
|
||||||
/** 重型版式:父级停滚后设为 true 才串行挂载(安卓) */
|
|
||||||
activate: { type: Boolean, default: false },
|
|
||||||
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
||||||
nineGridAnimate: { type: Boolean, default: false },
|
nineGridAnimate: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
@@ -262,6 +260,16 @@ let overlapSecondTimer = null
|
|||||||
const seq = createSequentialMount()
|
const seq = createSequentialMount()
|
||||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
const clearOverlapSecondTimer = () => {
|
const clearOverlapSecondTimer = () => {
|
||||||
@@ -294,10 +302,12 @@ const emitReady = () => {
|
|||||||
emit('ready')
|
emit('ready')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭九宫格动画时提前暖缓存 */
|
/** 关闭九宫格动画时提前暖缓存(优先展示用缩放图) */
|
||||||
const preloadNineGridImages = () => {
|
const preloadNineGridImages = () => {
|
||||||
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
|
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
|
if (!urls.length) return
|
||||||
preloadStarted = true
|
preloadStarted = true
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
@@ -481,22 +491,19 @@ const bindByType = async () => {
|
|||||||
}
|
}
|
||||||
if (SEQUENTIAL_SELF_TYPES.has(type)) {
|
if (SEQUENTIAL_SELF_TYPES.has(type)) {
|
||||||
await bindMultiObserver()
|
await bindMultiObserver()
|
||||||
} else if (props.activate && HOLD_ACTIVATE_TYPES.has(type)) {
|
|
||||||
await startSequentialMount()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
|
() => [
|
||||||
async (on) => {
|
props.page?.type,
|
||||||
if (!on) return
|
props.page?.images,
|
||||||
await startSequentialMount()
|
props.page?.imagesResized,
|
||||||
},
|
props.page?.img1,
|
||||||
{ flush: 'post' }
|
props.page?.img2,
|
||||||
)
|
props.page?.img1Resized,
|
||||||
|
props.page?.img2Resized,
|
||||||
watch(
|
],
|
||||||
() => [props.page?.type, props.page?.images, props.page?.img1, props.page?.img2],
|
|
||||||
async () => {
|
async () => {
|
||||||
seq.cancel()
|
seq.cancel()
|
||||||
olfsToken += 1
|
olfsToken += 1
|
||||||
@@ -589,7 +596,8 @@ function onScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewImage(src) {
|
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]
|
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,20 +505,58 @@
|
|||||||
<a-textarea v-model:value="page.desc" :rows="2" />
|
<a-textarea v-model:value="page.desc" :rows="2" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<!-- 图片上传区:按版式渲染 -->
|
<!-- 图片上传区:按版式渲染 -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mt-2 mb-2">
|
||||||
|
<div class="text-[11px] text-[#8A8680]">
|
||||||
|
原图用于预览;展示图为等比缩放后的体积(不裁切)。
|
||||||
|
<span v-if="resizeProgress" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
|
||||||
|
</div>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||||||
|
:loading="resizingPage"
|
||||||
|
:disabled="uploading || !pageHasOriginal(page)"
|
||||||
|
@click="batchResizeAndUpload(page)"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap gap-3 mt-2">
|
<div class="flex flex-wrap gap-3 mt-2">
|
||||||
<template v-if="page.type==='single'">
|
<template v-if="page.type==='single'">
|
||||||
<image-field v-model="page.img1" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" />
|
<div class="relative w-[96px]">
|
||||||
|
<image-field v-model="page.img1" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
|
||||||
|
<div v-if="page.img1Resized" class="text-center mt-1">
|
||||||
|
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||||
|
<div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page.type==='overlap'">
|
<template v-else-if="page.type==='overlap'">
|
||||||
<image-field v-model="page.img1" label="图一" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" />
|
<div class="relative w-[96px]">
|
||||||
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => page.img2 = u)" :uploading="uploading" />
|
<image-field v-model="page.img1" label="图一" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
|
||||||
|
<div v-if="page.img1Resized" class="text-center mt-1">
|
||||||
|
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||||
|
<div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-[96px]">
|
||||||
|
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => setOriginalAndClearResized(page, 'img2', u))" :uploading="uploading" />
|
||||||
|
<div v-if="page.img2Resized" class="text-center mt-1">
|
||||||
|
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||||
|
<div v-if="page.img2ResizedMeta" class="resized-meta">{{ page.img2ResizedMeta }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative">
|
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
|
||||||
<image-field v-model="page.images[iidx]"
|
<image-field v-model="page.images[iidx]"
|
||||||
:label="`图${iidx+1}`"
|
:label="`图${iidx+1}`"
|
||||||
@pick="openUpload(u => page.images[iidx] = u)"
|
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
|
||||||
:uploading="uploading" />
|
:uploading="uploading" />
|
||||||
|
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
|
||||||
|
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||||
|
<div v-if="page.imagesResizedMeta?.[iidx]" class="resized-meta">{{ page.imagesResizedMeta[iidx] }}</div>
|
||||||
|
</div>
|
||||||
<button v-if="isFlexible(page.type) && page.images.length > 1"
|
<button v-if="isFlexible(page.type) && page.images.length > 1"
|
||||||
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
|
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
|
||||||
@click="removeImage(page, iidx)" title="移除该图">✕</button>
|
@click="removeImage(page, iidx)" title="移除该图">✕</button>
|
||||||
@@ -698,6 +736,7 @@ import { useAdminStore } from '@/stores/admin'
|
|||||||
import ImageField from '@/components/ImageField.vue'
|
import ImageField from '@/components/ImageField.vue'
|
||||||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||||||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||||||
|
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
|
||||||
|
|
||||||
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||||
|
|
||||||
@@ -732,6 +771,8 @@ const activeTab = ref(readStoredTab())
|
|||||||
const activePhotoTab = ref('0')
|
const activePhotoTab = ref('0')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const resizingPage = ref(false)
|
||||||
|
const resizeProgress = ref('')
|
||||||
const uploadConfigSaving = ref(false)
|
const uploadConfigSaving = ref(false)
|
||||||
const tabLoading = ref(false)
|
const tabLoading = ref(false)
|
||||||
const rsvpLoading = ref(false)
|
const rsvpLoading = ref(false)
|
||||||
@@ -850,10 +891,42 @@ function onHeightFieldCustomVh(target, key, val) {
|
|||||||
target[key] = (val || 100) + 'vh'
|
target[key] = (val || 100) + 'vh'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncImagesResized(p) {
|
||||||
|
if (!Array.isArray(p.images)) {
|
||||||
|
delete p.imagesResized
|
||||||
|
delete p.imagesResizedMeta
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Array.isArray(p.imagesResized)) p.imagesResized = []
|
||||||
|
if (!Array.isArray(p.imagesResizedMeta)) p.imagesResizedMeta = []
|
||||||
|
while (p.imagesResized.length < p.images.length) p.imagesResized.push('')
|
||||||
|
while (p.imagesResizedMeta.length < p.images.length) p.imagesResizedMeta.push('')
|
||||||
|
if (p.imagesResized.length > p.images.length) p.imagesResized.length = p.images.length
|
||||||
|
if (p.imagesResizedMeta.length > p.images.length) p.imagesResizedMeta.length = p.images.length
|
||||||
|
}
|
||||||
|
|
||||||
function ensurePageShape(p) {
|
function ensurePageShape(p) {
|
||||||
if (p.type === 'single') { p.img1 = p.img1 || ''; delete p.img2; delete p.images }
|
if (p.type === 'single') {
|
||||||
else if (p.type === 'overlap') { p.img1 = p.img1 || ''; p.img2 = p.img2 || ''; delete p.images }
|
p.img1 = p.img1 || ''
|
||||||
else {
|
p.img1Resized = p.img1Resized || ''
|
||||||
|
p.img1ResizedMeta = p.img1ResizedMeta || ''
|
||||||
|
delete p.img2
|
||||||
|
delete p.img2Resized
|
||||||
|
delete p.img2ResizedMeta
|
||||||
|
delete p.images
|
||||||
|
delete p.imagesResized
|
||||||
|
delete p.imagesResizedMeta
|
||||||
|
} else if (p.type === 'overlap') {
|
||||||
|
p.img1 = p.img1 || ''
|
||||||
|
p.img2 = p.img2 || ''
|
||||||
|
p.img1Resized = p.img1Resized || ''
|
||||||
|
p.img2Resized = p.img2Resized || ''
|
||||||
|
p.img1ResizedMeta = p.img1ResizedMeta || ''
|
||||||
|
p.img2ResizedMeta = p.img2ResizedMeta || ''
|
||||||
|
delete p.images
|
||||||
|
delete p.imagesResized
|
||||||
|
delete p.imagesResizedMeta
|
||||||
|
} else {
|
||||||
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
||||||
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
||||||
const need = fixed[p.type] || 6
|
const need = fixed[p.type] || 6
|
||||||
@@ -863,9 +936,15 @@ function ensurePageShape(p) {
|
|||||||
while (p.images.length < need) p.images.push('')
|
while (p.images.length < need) p.images.push('')
|
||||||
if (p.images.length > need) p.images.length = need
|
if (p.images.length > need) p.images.length = need
|
||||||
}
|
}
|
||||||
delete p.img1; delete p.img2
|
syncImagesResized(p)
|
||||||
|
delete p.img1
|
||||||
|
delete p.img2
|
||||||
|
delete p.img1Resized
|
||||||
|
delete p.img2Resized
|
||||||
|
delete p.img1ResizedMeta
|
||||||
|
delete p.img2ResizedMeta
|
||||||
}
|
}
|
||||||
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
|
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
|
||||||
p.height = p.height || '100vh'
|
p.height = p.height || '100vh'
|
||||||
p.borderStyle = p.borderStyle || 'mat'
|
p.borderStyle = p.borderStyle || 'mat'
|
||||||
// 单图宽度设置(仅 single 版式使用,逐页配置)
|
// 单图宽度设置(仅 single 版式使用,逐页配置)
|
||||||
@@ -878,13 +957,118 @@ function ensurePageShape(p) {
|
|||||||
function isFlexible(type) {
|
function isFlexible(type) {
|
||||||
return ['masonry', 'carousel', 'polaroid'].includes(type)
|
return ['masonry', 'carousel', 'polaroid'].includes(type)
|
||||||
}
|
}
|
||||||
function addImage(page) { if (!Array.isArray(page.images)) page.images = []; page.images.push('') }
|
function addImage(page) {
|
||||||
function removeImage(page, i) { if (page.images.length > 1) page.images.splice(i, 1) }
|
if (!Array.isArray(page.images)) page.images = []
|
||||||
|
page.images.push('')
|
||||||
|
syncImagesResized(page)
|
||||||
|
}
|
||||||
|
function removeImage(page, i) {
|
||||||
|
if (page.images.length > 1) {
|
||||||
|
page.images.splice(i, 1)
|
||||||
|
if (Array.isArray(page.imagesResized)) page.imagesResized.splice(i, 1)
|
||||||
|
if (Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta.splice(i, 1)
|
||||||
|
syncImagesResized(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 图片排序:前移(-1) / 后移(+1)
|
// 图片排序:前移(-1) / 后移(+1)
|
||||||
function moveImage(page, i, dir) {
|
function moveImage(page, i, dir) {
|
||||||
const j = i + dir
|
const j = i + dir
|
||||||
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
|
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
|
||||||
const t = page.images[i]; page.images[i] = page.images[j]; page.images[j] = t
|
const t = page.images[i]; page.images[i] = page.images[j]; page.images[j] = t
|
||||||
|
syncImagesResized(page)
|
||||||
|
if (Array.isArray(page.imagesResized)) {
|
||||||
|
const r = page.imagesResized[i]
|
||||||
|
page.imagesResized[i] = page.imagesResized[j]
|
||||||
|
page.imagesResized[j] = r
|
||||||
|
}
|
||||||
|
if (Array.isArray(page.imagesResizedMeta)) {
|
||||||
|
const m = page.imagesResizedMeta[i]
|
||||||
|
page.imagesResizedMeta[i] = page.imagesResizedMeta[j]
|
||||||
|
page.imagesResizedMeta[j] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHasOriginal(page) {
|
||||||
|
return getSlotResizeSpecs(page).some((s) => !!s.getOriginal())
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOriginalAndClearResized(page, key, url) {
|
||||||
|
page[key] = url
|
||||||
|
page[`${key}Resized`] = ''
|
||||||
|
page[`${key}ResizedMeta`] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOriginalImageAt(page, iidx, url) {
|
||||||
|
page.images[iidx] = url
|
||||||
|
syncImagesResized(page)
|
||||||
|
page.imagesResized[iidx] = ''
|
||||||
|
page.imagesResizedMeta[iidx] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺 meta,或只有像素没有文件大小时,自动探测并补全 */
|
||||||
|
async function fillMissingResizedMeta(page) {
|
||||||
|
if (!page) return
|
||||||
|
if (page.type === 'single' || page.type === 'overlap') {
|
||||||
|
const keys = page.type === 'single' ? ['img1'] : ['img1', 'img2']
|
||||||
|
await Promise.all(keys.map(async (key) => {
|
||||||
|
const original = page[key]
|
||||||
|
const resized = page[`${key}Resized`]
|
||||||
|
const metaKey = `${key}ResizedMeta`
|
||||||
|
if (!resized || !original || metaHasFileSize(page[metaKey])) return
|
||||||
|
try {
|
||||||
|
page[metaKey] = await probeResizedMeta(original, resized)
|
||||||
|
} catch { /* 跨域/失效图忽略 */ }
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Array.isArray(page.images)) return
|
||||||
|
syncImagesResized(page)
|
||||||
|
await Promise.all(page.images.map(async (original, i) => {
|
||||||
|
const resized = page.imagesResized[i]
|
||||||
|
if (!resized || !original || metaHasFileSize(page.imagesResizedMeta[i])) return
|
||||||
|
try {
|
||||||
|
page.imagesResizedMeta[i] = await probeResizedMeta(original, resized)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchResizeAndUpload(page) {
|
||||||
|
const slots = getSlotResizeSpecs(page).filter((s) => !!s.getOriginal())
|
||||||
|
if (!slots.length) {
|
||||||
|
message.warning('请先上传原图')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resizingPage.value = true
|
||||||
|
resizeProgress.value = `0/${slots.length}`
|
||||||
|
let ok = 0
|
||||||
|
let fail = 0
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < slots.length; i += 1) {
|
||||||
|
const slot = slots[i]
|
||||||
|
resizeProgress.value = `${i + 1}/${slots.length}`
|
||||||
|
try {
|
||||||
|
const { file, meta } = await resizeSlotToFile(
|
||||||
|
slot.getOriginal(),
|
||||||
|
slot.maxWidth,
|
||||||
|
slot.maxHeight,
|
||||||
|
`resized-${page.type}-${slot.key}-${Date.now()}.jpg`,
|
||||||
|
)
|
||||||
|
const res = await uploadImage(file)
|
||||||
|
if (!res?.url) throw new Error('上传无返回地址')
|
||||||
|
slot.setResized(res.url, meta)
|
||||||
|
ok += 1
|
||||||
|
} catch (err) {
|
||||||
|
fail += 1
|
||||||
|
console.warn('[resize]', slot.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
|
||||||
|
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
|
||||||
|
else message.error('缩放上传全部失败,请确认原图可跨域读取')
|
||||||
|
} finally {
|
||||||
|
resizingPage.value = false
|
||||||
|
resizeProgress.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相册页操作
|
// 相册页操作
|
||||||
@@ -1063,7 +1247,13 @@ function applySectionData(section, loaded) {
|
|||||||
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
|
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
|
||||||
}
|
}
|
||||||
Object.assign(cfg, loaded)
|
Object.assign(cfg, loaded)
|
||||||
if (section === 'photos') setActivePhotoIndex(currentPhotoIndex.value)
|
if (section === 'photos') {
|
||||||
|
setActivePhotoIndex(currentPhotoIndex.value)
|
||||||
|
nextTick(() => {
|
||||||
|
const page = cfg.photoPages[currentPhotoIndex.value]
|
||||||
|
if (page) fillMissingResizedMeta(page)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfigSection(section) {
|
async function loadConfigSection(section) {
|
||||||
@@ -1281,6 +1471,16 @@ watch(activeTab, (tab) => {
|
|||||||
onTabActivate(tab)
|
onTabActivate(tab)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 进入相册页 / 切换页签时,为缺 meta 的展示图自动探测尺寸
|
||||||
|
watch(
|
||||||
|
() => [activeTab.value, currentPhotoIndex.value],
|
||||||
|
([tab]) => {
|
||||||
|
if (tab !== 'photos') return
|
||||||
|
const page = cfg.photoPages[currentPhotoIndex.value]
|
||||||
|
if (page) fillMissingResizedMeta(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const onResize = () => { regionChart?.resize() }
|
const onResize = () => { regionChart?.resize() }
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -1294,6 +1494,14 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.resized-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #8A8680;
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.admin-tabs :deep(.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn) { color: #a88c6b; }
|
.admin-tabs :deep(.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn) { color: #a88c6b; }
|
||||||
.admin-tabs :deep(.ant-tabs-ink-bar) { background: #a88c6b; }
|
.admin-tabs :deep(.ant-tabs-ink-bar) { background: #a88c6b; }
|
||||||
.admin-tabs :deep(.ant-tabs-tab:hover .ant-tabs-tab-btn) { color: #967b5c; }
|
.admin-tabs :deep(.ant-tabs-tab:hover .ant-tabs-tab-btn) { color: #967b5c; }
|
||||||
|
|||||||
@@ -309,18 +309,16 @@
|
|||||||
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx"
|
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx"
|
||||||
class="page-section p-6 relative"
|
class="page-section p-6 relative"
|
||||||
:class="data.scrollMode === 'snap' ? 'snap-start' : ''"
|
:class="data.scrollMode === 'snap' ? 'snap-start' : ''"
|
||||||
:style="pageHeightStyle(page)"
|
:style="pageHeightStyle(page)">
|
||||||
:data-gallery-hold="isHoldGalleryType(page.type) ? String(pIdx) : undefined">
|
|
||||||
<div class="absolute inset-0 bg-white/40 -z-10"></div>
|
<div class="absolute inset-0 bg-white/40 -z-10"></div>
|
||||||
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
||||||
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
||||||
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
|
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
|
||||||
<PhotoGallery
|
<PhotoGallery
|
||||||
:page="page"
|
:page="page"
|
||||||
:activate="isHoldGalleryType(page.type) ? !!galleryActivate[pIdx] : false"
|
|
||||||
:nine-grid-animate="!!data.nineGridAnimate"
|
:nine-grid-animate="!!data.nineGridAnimate"
|
||||||
@preview-image="openImagePreview"
|
@preview-image="openImagePreview"
|
||||||
@ready="onGalleryReady(pIdx)"
|
@ready="onGalleryReady"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -611,9 +609,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 注意:不可对子 a-image 设 preview=false,否则不会注册进 Group,灯箱 src 为空 -->
|
||||||
<a-image-preview-group :preview="albumPreviewOptions">
|
<a-image-preview-group :preview="albumPreviewOptions">
|
||||||
<div class="album-preview-registry" aria-hidden="true">
|
<div class="album-preview-registry" aria-hidden="true">
|
||||||
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" :preview="false" />
|
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" />
|
||||||
</div>
|
</div>
|
||||||
</a-image-preview-group>
|
</a-image-preview-group>
|
||||||
|
|
||||||
@@ -748,13 +747,6 @@ const likeBusy = ref(false)
|
|||||||
let likePulseTimer = null
|
let likePulseTimer = null
|
||||||
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||||||
const albumPreviewImages = ref([])
|
const albumPreviewImages = ref([])
|
||||||
/** 九宫格 / 一大五小:安卓进视口中线后停滚再串行加载
|
|
||||||
* 错落/瀑布/拍立得不停滚,由组件内 IO 分步挂载
|
|
||||||
* iOS 全部跳过停滚,近视口 IO 串行(保留滚动连续性) */
|
|
||||||
const HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small'])
|
|
||||||
const isHoldGalleryType = (type) => !isIOS && HOLD_GALLERY_TYPES.has(type)
|
|
||||||
const galleryActivate = reactive({})
|
|
||||||
const galleryHold = ref(false)
|
|
||||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||||
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
||||||
const danmakuColorOptions = DANMAKU_COLORS
|
const danmakuColorOptions = DANMAKU_COLORS
|
||||||
@@ -793,25 +785,44 @@ const scheduleBlessingAutoClose = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
||||||
let freeScrollLastTs = 0
|
|
||||||
/** free 模式自维护的滚动偏移,避免每帧读 scrollY 触发 iOS 同步重排/撕裂 */
|
/** free 模式自维护的滚动偏移,避免每帧读 scrollY 触发 iOS 同步重排/撕裂 */
|
||||||
let freeScrollOffset = -1
|
let freeScrollOffset = -1
|
||||||
let galleryObserver = null
|
/** 绝对时间锚点:用 elapsed 算位移,掉帧不再永久丢进度(iOS 更容易掉帧导致比安卓慢) */
|
||||||
let galleryRevealTimer = null
|
let freeScrollOriginTs = 0
|
||||||
const galleryDone = new Set()
|
let freeScrollOriginOffset = 0
|
||||||
const BOTTOM_RETURN_DELAY = 30000
|
const BOTTOM_RETURN_DELAY = 30000
|
||||||
|
/** 每 freeScrollInterval ms 推进的基准像素(与配置滑块联动) */
|
||||||
const FREE_SCROLL_BASE_STEP = 1.2
|
const FREE_SCROLL_BASE_STEP = 1.2
|
||||||
/** ready 后极短缓冲再恢复滚动(串行等待已在 PhotoGallery 内完成) */
|
/** iOS 合成器对高频 scrollTo 有轻微滞后,略提速对齐安卓观感 */
|
||||||
const GALLERY_RESUME_DELAY_MS = 200
|
const FREE_SCROLL_IOS_BOOST = 1.22
|
||||||
|
|
||||||
const freeInterval = computed(() => {
|
const freeInterval = computed(() => {
|
||||||
const v = Number(data.value.freeScrollInterval)
|
const v = Number(data.value.freeScrollInterval)
|
||||||
return v >= 10 && v <= 1000 ? v : 50
|
return v >= 10 && v <= 1000 ? v : 50
|
||||||
})
|
})
|
||||||
|
/** px / ms */
|
||||||
|
const freeScrollVelocity = computed(() => {
|
||||||
|
const base = FREE_SCROLL_BASE_STEP / freeInterval.value
|
||||||
|
return isIOS ? base * FREE_SCROLL_IOS_BOOST : base
|
||||||
|
})
|
||||||
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
|
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
|
||||||
|
const invalidateFreeScrollAnchor = () => {
|
||||||
|
freeScrollOffset = -1
|
||||||
|
freeScrollOriginTs = 0
|
||||||
|
}
|
||||||
const restartAutoScroll = () => {
|
const restartAutoScroll = () => {
|
||||||
if (snapTimer || rafId) startAutoScroll()
|
if (snapTimer || rafId) startAutoScroll()
|
||||||
}
|
}
|
||||||
|
const applyFreeScrollTop = (y) => {
|
||||||
|
// iOS:直接写 scrollTop,减少 scrollTo 被系统平滑插值拖慢的观感
|
||||||
|
if (isIOS) {
|
||||||
|
const top = Math.max(0, y)
|
||||||
|
document.documentElement.scrollTop = top
|
||||||
|
document.body.scrollTop = top
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.scrollTo(0, y)
|
||||||
|
}
|
||||||
const switchDuration = computed(() => {
|
const switchDuration = computed(() => {
|
||||||
const v = Number(data.value.pageSwitchDuration)
|
const v = Number(data.value.pageSwitchDuration)
|
||||||
return v >= 0 && v <= 5000 ? v : 800
|
return v >= 0 && v <= 5000 ? v : 800
|
||||||
@@ -883,10 +894,15 @@ const normalizeImages = (images = []) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localAlbumImages = () => {
|
const localAlbumImages = () => {
|
||||||
|
// 灯箱优先原图;无原图时再退缩放图
|
||||||
const images = [data.value.heroImg, data.value.endImg]
|
const images = [data.value.heroImg, data.value.endImg]
|
||||||
;(data.value.photoPages || []).forEach((page) => {
|
;(data.value.photoPages || []).forEach((page) => {
|
||||||
images.push(page.img1, page.img2)
|
images.push(page.img1 || page.img1Resized, page.img2 || page.img2Resized)
|
||||||
if (Array.isArray(page.images)) images.push(...page.images)
|
if (Array.isArray(page.images)) {
|
||||||
|
page.images.forEach((src, i) => {
|
||||||
|
images.push(src || page.imagesResized?.[i])
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return normalizeImages(images)
|
return normalizeImages(images)
|
||||||
}
|
}
|
||||||
@@ -920,16 +936,21 @@ const albumPreviewOptions = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const openImagePreview = async (src) => {
|
const openImagePreview = async (src) => {
|
||||||
if (!src) return
|
// 兼容 computed / 非字符串误传
|
||||||
|
const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim()
|
||||||
|
if (!url || url === '[object Object]') return
|
||||||
await loadAlbumPreviewImages()
|
await loadAlbumPreviewImages()
|
||||||
let images = albumPreviewImages.value.length ? albumPreviewImages.value : [src]
|
let images = albumPreviewImages.value.length ? [...albumPreviewImages.value] : []
|
||||||
let index = images.indexOf(src)
|
let index = images.indexOf(url)
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
images = [src, ...images]
|
images = [url, ...images]
|
||||||
albumPreviewImages.value = images
|
albumPreviewImages.value = images
|
||||||
index = 0
|
index = 0
|
||||||
}
|
}
|
||||||
previewIndex.value = index
|
previewIndex.value = index
|
||||||
|
// 等 a-image 挂载并 registerImage 后再打开,避免灯箱空白
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
cancelAnimationFrame(animId)
|
cancelAnimationFrame(animId)
|
||||||
@@ -967,12 +988,12 @@ const handleUserInteraction = () => {
|
|||||||
cancelAnimationFrame(animId)
|
cancelAnimationFrame(animId)
|
||||||
clearTimeout(pauseTimer)
|
clearTimeout(pauseTimer)
|
||||||
// 立刻失效,避免暂停窗口内其它路径用旧 offset 写回顶部
|
// 立刻失效,避免暂停窗口内其它路径用旧 offset 写回顶部
|
||||||
freeScrollOffset = -1
|
invalidateFreeScrollAnchor()
|
||||||
if (isAtBottom()) scheduleBottomReturn(true)
|
if (isAtBottom()) scheduleBottomReturn(true)
|
||||||
pauseTimer = setTimeout(() => {
|
pauseTimer = setTimeout(() => {
|
||||||
isInteracting.value = false
|
isInteracting.value = false
|
||||||
// 恢复自动滚动前,让下一帧重新同步真实滚动位置
|
// 恢复自动滚动前,让下一帧重新同步真实滚动位置
|
||||||
freeScrollOffset = -1
|
invalidateFreeScrollAnchor()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,7 +1028,7 @@ const smoothScrollTo = (target, duration) => {
|
|||||||
const start = pageScrollTop(), dist = target - start
|
const start = pageScrollTop(), dist = target - start
|
||||||
if (!dist) return
|
if (!dist) return
|
||||||
// 标记 offset 失效,下一次 free 滚动重新从真实位置开始
|
// 标记 offset 失效,下一次 free 滚动重新从真实位置开始
|
||||||
freeScrollOffset = -1
|
invalidateFreeScrollAnchor()
|
||||||
if (duration <= 0) { window.scrollTo(0, target); return }
|
if (duration <= 0) { window.scrollTo(0, target); return }
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
const tick = (now) => {
|
const tick = (now) => {
|
||||||
@@ -1041,52 +1062,10 @@ const canAutoAdvance = () =>
|
|||||||
&& !previewVisible.value
|
&& !previewVisible.value
|
||||||
&& !showMapOptions.value
|
&& !showMapOptions.value
|
||||||
&& !showOpenInBrowser.value
|
&& !showOpenInBrowser.value
|
||||||
&& !galleryHold.value
|
|
||||||
|
|
||||||
const beginGalleryGate = (pIdx) => {
|
/** 图挂载后刷新文档高度(画廊不停滚,仅校正 scrollMax) */
|
||||||
if (galleryDone.has(pIdx)) return
|
const onGalleryReady = () => {
|
||||||
galleryDone.add(pIdx)
|
|
||||||
galleryHold.value = true
|
|
||||||
galleryActivate[pIdx] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onGalleryReady = (pIdx) => {
|
|
||||||
// iOS 跳过 hold,但仍需刷新文档高度(图挂载后 scrollMax 可能变化)
|
|
||||||
refreshScrollMax()
|
refreshScrollMax()
|
||||||
if (!galleryDone.has(pIdx)) return
|
|
||||||
clearTimeout(galleryRevealTimer)
|
|
||||||
galleryRevealTimer = setTimeout(() => {
|
|
||||||
galleryHold.value = false
|
|
||||||
galleryRevealTimer = null
|
|
||||||
}, GALLERY_RESUME_DELAY_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindGalleryHoldObserver = () => {
|
|
||||||
galleryObserver?.disconnect()
|
|
||||||
galleryObserver = null
|
|
||||||
if (typeof IntersectionObserver === 'undefined') return
|
|
||||||
const nodes = scrollContainerRef.value?.querySelectorAll('[data-gallery-hold]')
|
|
||||||
if (!nodes?.length) return
|
|
||||||
// 区块抵达「自下而上 75%」线后再停滚+activate(= 距视口顶 25%)
|
|
||||||
galleryObserver = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const vh = window.innerHeight || 1
|
|
||||||
const lineY = vh * (1 - 0.9) // 从下往上 75%
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isIntersecting) continue
|
|
||||||
const rect = entry.boundingClientRect
|
|
||||||
// 区块跨越该水平线
|
|
||||||
if (rect.top > lineY || rect.bottom < lineY) continue
|
|
||||||
const raw = entry.target.getAttribute('data-gallery-hold')
|
|
||||||
if (raw == null) continue
|
|
||||||
const pIdx = Number(raw)
|
|
||||||
if (!Number.isFinite(pIdx) || galleryDone.has(pIdx)) continue
|
|
||||||
beginGalleryGate(pIdx)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1], rootMargin: '0px' }
|
|
||||||
)
|
|
||||||
nodes.forEach((el) => galleryObserver.observe(el))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAutoScroll = () => {
|
const startAutoScroll = () => {
|
||||||
@@ -1101,21 +1080,27 @@ const startAutoScroll = () => {
|
|||||||
if (canAutoAdvance()) goNextSection()
|
if (canAutoAdvance()) goNextSection()
|
||||||
}, 4500)
|
}, 4500)
|
||||||
} else {
|
} else {
|
||||||
freeScrollLastTs = 0
|
invalidateFreeScrollAnchor()
|
||||||
const tickFreeScroll = (now) => {
|
const tickFreeScroll = (now) => {
|
||||||
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
|
|
||||||
freeScrollLastTs = now
|
|
||||||
if (canAutoAdvance()) {
|
if (canAutoAdvance()) {
|
||||||
// 失效后每帧推进前同步真实位置(打断/开关自动滚后都会走到这里)
|
// 重新锚定:从真实位置 + 当前时刻起算,掉帧用绝对时间补齐
|
||||||
if (freeScrollOffset < 0) freeScrollOffset = pageScrollTop()
|
if (freeScrollOriginTs <= 0 || freeScrollOffset < 0) {
|
||||||
|
freeScrollOriginOffset = pageScrollTop()
|
||||||
|
freeScrollOriginTs = now
|
||||||
|
freeScrollOffset = freeScrollOriginOffset
|
||||||
|
}
|
||||||
const max = pageScrollMax()
|
const max = pageScrollMax()
|
||||||
|
freeScrollOffset = freeScrollOriginOffset + freeScrollVelocity.value * (now - freeScrollOriginTs)
|
||||||
if (max - freeScrollOffset <= 8) {
|
if (max - freeScrollOffset <= 8) {
|
||||||
|
freeScrollOffset = max
|
||||||
|
applyFreeScrollTop(freeScrollOffset)
|
||||||
scheduleBottomReturn()
|
scheduleBottomReturn()
|
||||||
} else {
|
} else {
|
||||||
// 单向写入,避免 read-then-write 触发 iOS 合成器/主线程同步抖动
|
applyFreeScrollTop(freeScrollOffset)
|
||||||
freeScrollOffset += FREE_SCROLL_BASE_STEP * (dt / freeInterval.value)
|
|
||||||
window.scrollTo(0, freeScrollOffset)
|
|
||||||
}
|
}
|
||||||
|
} else if (freeScrollOriginTs > 0) {
|
||||||
|
// 暂停期间丢弃锚点,恢复后从用户当前位置续滚
|
||||||
|
invalidateFreeScrollAnchor()
|
||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(tickFreeScroll)
|
rafId = requestAnimationFrame(tickFreeScroll)
|
||||||
}
|
}
|
||||||
@@ -1156,10 +1141,11 @@ const completeIntro = () => {
|
|||||||
const toggleAutoScroll = () => {
|
const toggleAutoScroll = () => {
|
||||||
isAutoScroll.value = !isAutoScroll.value
|
isAutoScroll.value = !isAutoScroll.value
|
||||||
if (isAutoScroll.value) {
|
if (isAutoScroll.value) {
|
||||||
freeScrollOffset = -1
|
invalidateFreeScrollAnchor()
|
||||||
if (isAtBottom()) scheduleBottomReturn(true)
|
if (isAtBottom()) scheduleBottomReturn(true)
|
||||||
} else {
|
} else {
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
|
invalidateFreeScrollAnchor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,7 +1323,12 @@ onMounted(async () => {
|
|||||||
const next = { ...p }
|
const next = { ...p }
|
||||||
if (next.img1) next.img1 = optimizeImageUrl(next.img1)
|
if (next.img1) next.img1 = optimizeImageUrl(next.img1)
|
||||||
if (next.img2) next.img2 = optimizeImageUrl(next.img2)
|
if (next.img2) next.img2 = optimizeImageUrl(next.img2)
|
||||||
|
if (next.img1Resized) next.img1Resized = optimizeImageUrl(next.img1Resized)
|
||||||
|
if (next.img2Resized) next.img2Resized = optimizeImageUrl(next.img2Resized)
|
||||||
if (Array.isArray(next.images)) next.images = next.images.map((u) => optimizeImageUrl(u))
|
if (Array.isArray(next.images)) next.images = next.images.map((u) => optimizeImageUrl(u))
|
||||||
|
if (Array.isArray(next.imagesResized)) {
|
||||||
|
next.imagesResized = next.imagesResized.map((u) => (u ? optimizeImageUrl(u) : u))
|
||||||
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1373,7 +1364,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
bindGalleryHoldObserver()
|
|
||||||
refreshScrollMax()
|
refreshScrollMax()
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
window.addEventListener('resize', refreshScrollMax, { passive: true })
|
window.addEventListener('resize', refreshScrollMax, { passive: true })
|
||||||
@@ -1384,10 +1374,6 @@ onUnmounted(() => {
|
|||||||
document.documentElement.classList.remove('page-scroll-snap')
|
document.documentElement.classList.remove('page-scroll-snap')
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', refreshScrollMax)
|
window.removeEventListener('resize', refreshScrollMax)
|
||||||
galleryObserver?.disconnect()
|
|
||||||
galleryObserver = null
|
|
||||||
clearTimeout(galleryRevealTimer)
|
|
||||||
galleryRevealTimer = null
|
|
||||||
cancelAnimationFrame(scrollRaf)
|
cancelAnimationFrame(scrollRaf)
|
||||||
scrollRaf = 0
|
scrollRaf = 0
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
@@ -1406,18 +1392,10 @@ watch(
|
|||||||
() => data.value.photoPages,
|
() => data.value.photoPages,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
bindGalleryHoldObserver()
|
|
||||||
refreshScrollMax()
|
refreshScrollMax()
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
watch(
|
|
||||||
() => data.value.nineGridAnimate,
|
|
||||||
async () => {
|
|
||||||
await nextTick()
|
|
||||||
bindGalleryHoldObserver()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
watch(freeScrollSignature, restartAutoScroll)
|
watch(freeScrollSignature, restartAutoScroll)
|
||||||
watch(
|
watch(
|
||||||
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||||
|
|||||||
Reference in New Issue
Block a user