更新页面效果,但是会卡顿
This commit is contained in:
@@ -283,6 +283,47 @@ func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config Uplo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectAlbumImages(configData string) []string {
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
images := []string{}
|
||||||
|
add := func(value interface{}) {
|
||||||
|
src, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src = strings.TrimSpace(src)
|
||||||
|
if src == "" || seen[src] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[src] = true
|
||||||
|
images = append(images, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(raw["heroImg"])
|
||||||
|
add(raw["endImg"])
|
||||||
|
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
||||||
|
for _, item := range pages {
|
||||||
|
page, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(page["img1"])
|
||||||
|
add(page["img2"])
|
||||||
|
if pageImages, ok := page["images"].([]interface{}); ok {
|
||||||
|
for _, src := range pageImages {
|
||||||
|
add(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
initDB()
|
initDB()
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
@@ -320,6 +361,15 @@ func main() {
|
|||||||
c.JSON(200, gin.H{"code": 200, "data": config.ConfigData})
|
c.JSON(200, gin.H{"code": 200, "data": config.ConfigData})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.GET("/album", func(c *gin.Context) {
|
||||||
|
var config TemplateConfig
|
||||||
|
if err := db.First(&config, 1).Error; err != nil {
|
||||||
|
c.JSON(200, gin.H{"code": 200, "data": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
|
||||||
|
})
|
||||||
|
|
||||||
api.POST("/config", func(c *gin.Context) {
|
api.POST("/config", func(c *gin.Context) {
|
||||||
if !requireAdmin(c) {
|
if !requireAdmin(c) {
|
||||||
return
|
return
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -46,6 +46,10 @@ export function getConfig() {
|
|||||||
return service.get('/config')
|
return service.get('/config')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAlbumImages() {
|
||||||
|
return service.get('/album')
|
||||||
|
}
|
||||||
|
|
||||||
export function saveConfig(payload) {
|
export function saveConfig(payload) {
|
||||||
return service.post('/config', payload)
|
return service.post('/config', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { onMounted, onUnmounted, watch, ref } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
petalEnabled: { type: Boolean, default: true },
|
petalEnabled: { type: Boolean, default: true },
|
||||||
bubbleEnabled: { type: Boolean, default: true },
|
bubbleEnabled: { type: Boolean, default: true },
|
||||||
|
active: { type: Boolean, default: true },
|
||||||
|
performanceMode: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const canvasRef = ref(null)
|
const canvasRef = ref(null)
|
||||||
@@ -22,6 +24,7 @@ let width = 0
|
|||||||
let height = 0
|
let height = 0
|
||||||
let dpr = 1
|
let dpr = 1
|
||||||
let lastTime = 0
|
let lastTime = 0
|
||||||
|
let lastPaint = 0
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
let reduceMotion = false
|
let reduceMotion = false
|
||||||
|
|
||||||
@@ -52,14 +55,16 @@ function resetHeart(h, initial = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seedParticles() {
|
function seedParticles() {
|
||||||
|
const petalCount = props.performanceMode ? 8 : 18
|
||||||
|
const heartCount = props.performanceMode ? 5 : 12
|
||||||
petals.length = 0
|
petals.length = 0
|
||||||
hearts.length = 0
|
hearts.length = 0
|
||||||
for (let i = 0; i < 18; i += 1) {
|
for (let i = 0; i < petalCount; i += 1) {
|
||||||
const p = {}
|
const p = {}
|
||||||
resetPetal(p, true)
|
resetPetal(p, true)
|
||||||
petals.push(p)
|
petals.push(p)
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 12; i += 1) {
|
for (let i = 0; i < heartCount; i += 1) {
|
||||||
const h = {}
|
const h = {}
|
||||||
resetHeart(h, true)
|
resetHeart(h, true)
|
||||||
hearts.push(h)
|
hearts.push(h)
|
||||||
@@ -72,7 +77,7 @@ function resize() {
|
|||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
width = Math.max(1, rect.width)
|
width = Math.max(1, rect.width)
|
||||||
height = Math.max(1, rect.height)
|
height = Math.max(1, rect.height)
|
||||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
dpr = props.performanceMode ? 1 : Math.min(window.devicePixelRatio || 1, 1.5)
|
||||||
canvas.width = Math.round(width * dpr)
|
canvas.width = Math.round(width * dpr)
|
||||||
canvas.height = Math.round(height * dpr)
|
canvas.height = Math.round(height * dpr)
|
||||||
ctx = canvas.getContext('2d')
|
ctx = canvas.getContext('2d')
|
||||||
@@ -119,11 +124,24 @@ function drawHeart(h, now) {
|
|||||||
|
|
||||||
function tick(now) {
|
function tick(now) {
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
if (!props.active || reduceMotion || (!props.petalEnabled && !props.bubbleEnabled)) {
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
rafId = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameInterval = props.performanceMode ? 50 : 16.7
|
||||||
|
if (lastPaint && now - lastPaint < frameInterval) {
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const delta = Math.min(48, now - (lastTime || now)) / 1000
|
const delta = Math.min(48, now - (lastTime || now)) / 1000
|
||||||
lastTime = now
|
lastTime = now
|
||||||
|
lastPaint = now
|
||||||
ctx.clearRect(0, 0, width, height)
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
if (!reduceMotion && props.petalEnabled) {
|
if (props.petalEnabled) {
|
||||||
for (const p of petals) {
|
for (const p of petals) {
|
||||||
p.y += p.speed * delta
|
p.y += p.speed * delta
|
||||||
p.rotation += p.rotationSpeed * delta
|
p.rotation += p.rotationSpeed * delta
|
||||||
@@ -132,7 +150,7 @@ function tick(now) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reduceMotion && props.bubbleEnabled) {
|
if (props.bubbleEnabled) {
|
||||||
for (const h of hearts) {
|
for (const h of hearts) {
|
||||||
h.y -= h.speed * delta
|
h.y -= h.speed * delta
|
||||||
if (h.y < -80) resetHeart(h)
|
if (h.y < -80) resetHeart(h)
|
||||||
@@ -146,6 +164,12 @@ function tick(now) {
|
|||||||
function start() {
|
function start() {
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
lastTime = 0
|
lastTime = 0
|
||||||
|
lastPaint = 0
|
||||||
|
if (!props.active || reduceMotion || (!props.petalEnabled && !props.bubbleEnabled)) {
|
||||||
|
ctx?.clearRect(0, 0, width, height)
|
||||||
|
rafId = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
rafId = requestAnimationFrame(tick)
|
rafId = requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +181,13 @@ onMounted(() => {
|
|||||||
start()
|
start()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [props.petalEnabled, props.bubbleEnabled], start)
|
watch(
|
||||||
|
() => [props.petalEnabled, props.bubbleEnabled, props.active, props.performanceMode],
|
||||||
|
() => {
|
||||||
|
resize()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
@@ -173,5 +203,7 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
contain: strict;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,18 +10,18 @@
|
|||||||
<!-- 单图(默认居中卡幅 / 可配置宽度铺满) -->
|
<!-- 单图(默认居中卡幅 / 可配置宽度铺满) -->
|
||||||
<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"><img :src="page.img1" /></div>
|
<div :class="singleFrameClass"><img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错落双图(交错拼贴:左上大图 + 右下压角叠放,微旋转;clamp 封顶不溢出) -->
|
<!-- 错落双图(交错拼贴:左上大图 + 右下压角叠放,微旋转;clamp 封顶不溢出) -->
|
||||||
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
|
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
|
||||||
<div v-reveal="{ variant: 'left', delay: 100 }"
|
<div v-reveal="{ variant: 'left', delay: 100 }"
|
||||||
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2" :class="frameClass">
|
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2" :class="frameClass">
|
||||||
<img :src="page.img1" />
|
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
<div v-reveal="{ variant: 'right', delay: 300 }"
|
<div v-reveal="{ variant: 'right', delay: 300 }"
|
||||||
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3" :class="frameClass">
|
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3" :class="frameClass">
|
||||||
<img :src="page.img2" />
|
<img :src="page.img2" class="preview-img" @click.stop="previewImage(page.img2)" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
<div v-else-if="page.type === 'one-large-five-small'"
|
<div v-else-if="page.type === 'one-large-five-small'"
|
||||||
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
|
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
|
||||||
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative" :class="frameClass">
|
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative" :class="frameClass">
|
||||||
<img :src="page.images[0]" class="hover:scale-105 transition-transform duration-1000" />
|
<img :src="page.images[0]" class="preview-img hover:scale-105 transition-transform duration-1000" @click.stop="previewImage(page.images[0])" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative" :class="frameClass">
|
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative" :class="frameClass">
|
||||||
<img :src="page.images[i]" />
|
<img :src="page.images[i]" class="preview-img" @click.stop="previewImage(page.images[i])" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx"
|
<div v-for="(img, imgIdx) in page.images" :key="imgIdx"
|
||||||
v-reveal="getRevealConfig(imgIdx)"
|
v-reveal="getRevealConfig(imgIdx)"
|
||||||
class="aspect-square relative" :class="frameClass">
|
class="aspect-square relative" :class="frameClass">
|
||||||
<img :src="img" class="nine-grid-img" />
|
<img :src="img" class="nine-grid-img preview-img" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
|
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
|
||||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }"
|
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }"
|
||||||
class="g-masonry-item" :class="frameClass">
|
class="g-masonry-item" :class="frameClass">
|
||||||
<img :src="img" />
|
<img :src="img" class="preview-img" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<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="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
|
||||||
<img :src="img" />
|
<img :src="img" class="preview-img" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
|
||||||
</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>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
|
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
|
||||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
|
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
|
||||||
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
|
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
|
||||||
<img :src="img" />
|
<img :src="img" class="preview-img" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@ import { borderClass } from '@/composables/borderStyles'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
page: { type: Object, required: true },
|
page: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['preview-image', 'layout-change'])
|
||||||
|
|
||||||
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
|
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
|
||||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||||||
@@ -130,6 +131,14 @@ function onScroll() {
|
|||||||
if (i !== index.value) index.value = i
|
if (i !== index.value) index.value = i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewImage(src) {
|
||||||
|
if (src) emit('preview-image', src)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyLayoutChange() {
|
||||||
|
emit('layout-change')
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 拍立得旋转/位移 ---------- */
|
/* ---------- 拍立得旋转/位移 ---------- */
|
||||||
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
|
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
|
||||||
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
|
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
|
||||||
@@ -163,13 +172,14 @@ function getRevealConfig(imgIdx) {
|
|||||||
// 左右列
|
// 左右列
|
||||||
return {
|
return {
|
||||||
variant: col === 0 ? 'left' : 'right',
|
variant: col === 0 ? 'left' : 'right',
|
||||||
delay: 80 + ((imgIdx +80) * 12) ,
|
delay: 80 + ((imgIdx +80) * 20) ,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gallery { width: 100%; font-family: var(--font-sans); }
|
.gallery { width: 100%; font-family: var(--font-sans); }
|
||||||
|
.preview-img { cursor: zoom-in; }
|
||||||
.gallery-title {
|
.gallery-title {
|
||||||
font-family: var(--font-calligraphy);
|
font-family: var(--font-calligraphy);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -30,10 +30,15 @@
|
|||||||
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
|
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
|
||||||
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
|
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
|
||||||
|
|
||||||
<CanvasEffects :petal-enabled="data.petalEnabled" :bubble-enabled="data.bubbleEnabled" />
|
<CanvasEffects
|
||||||
|
:petal-enabled="data.petalEnabled"
|
||||||
|
:bubble-enabled="data.bubbleEnabled"
|
||||||
|
:active="effectsActive"
|
||||||
|
:performance-mode="effectsPerformanceMode"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 顶部滚动进度条 -->
|
<!-- 顶部滚动进度条 -->
|
||||||
<div class="absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
|
<div ref="progressBarRef" class="absolute top-0 left-0 w-full h-[2px] bg-[#a88c6b] z-[55] origin-left transform-gpu scale-x-0"></div>
|
||||||
|
|
||||||
<!-- 右上角控制台 -->
|
<!-- 右上角控制台 -->
|
||||||
<div class="absolute top-6 right-5 z-50 flex flex-col items-end gap-4">
|
<div class="absolute top-6 right-5 z-50 flex flex-col items-end gap-4">
|
||||||
@@ -115,7 +120,8 @@
|
|||||||
<div class="formal-copy flex justify-center gap-9 h-[22rem] text-[#3A3A3A]">
|
<div class="formal-copy flex justify-center gap-9 h-[22rem] text-[#3A3A3A]">
|
||||||
<span class="vertical-text formal-main calligraphy-strong">{{ data.formalText1 }}</span>
|
<span class="vertical-text formal-main calligraphy-strong">{{ data.formalText1 }}</span>
|
||||||
<span class="vertical-text formal-main formal-main-offset calligraphy-strong">{{ data.formalText2 }}</span>
|
<span class="vertical-text formal-main formal-main-offset calligraphy-strong">{{ data.formalText2 }}</span>
|
||||||
<span class="vertical-text formal-side calligraphy ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-64 mt-8 tracking-[0.28em]">诚挚邀请您见证我们的幸福</span>
|
<span class="vertical-text formal-side calligraphy ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-64 mt-8 tracking-[0.28em]">见证我们的幸福</span>
|
||||||
|
<span class="vertical-text formal-side calligraphy ml-4 h-64 mt-8 tracking-[0.28em]">诚挚邀请您</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +132,7 @@
|
|||||||
<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 :page="page" />
|
<PhotoGallery :page="page" @preview-image="openImagePreview" @layout-change="scheduleScrollMetricsUpdate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,12 +300,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a-image-preview-group :preview="albumPreviewOptions">
|
||||||
|
<div class="album-preview-registry" aria-hidden="true">
|
||||||
|
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" />
|
||||||
|
</div>
|
||||||
|
</a-image-preview-group>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { getConfig, submitRsvp } from '@/api/wedding'
|
import { getAlbumImages, getConfig, submitRsvp } from '@/api/wedding'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||||||
import PhotoGallery from '@/components/PhotoGallery.vue'
|
import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||||
@@ -319,13 +331,17 @@ const data = ref({"date": "2026年9月4日 星期五", "bride": "李逸烁", "gr
|
|||||||
|
|
||||||
const audio = useAudio()
|
const audio = useAudio()
|
||||||
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
|
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
|
||||||
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
|
const isInteracting = ref(false), scrollContainerRef = ref(null), progressBarRef = ref(null), progress = ref(0)
|
||||||
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
|
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
|
||||||
|
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||||||
|
const albumPreviewImages = ref([])
|
||||||
|
const maxScrollTop = ref(0)
|
||||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||||
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
|
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
|
||||||
|
|
||||||
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, metricsRaf = null
|
||||||
let freeScrollLastTs = 0
|
let freeScrollLastTs = 0
|
||||||
|
let freeScrollCarry = 0
|
||||||
const BOTTOM_RETURN_DELAY = 30000
|
const BOTTOM_RETURN_DELAY = 30000
|
||||||
const FREE_SCROLL_BASE_STEP = 1.2
|
const FREE_SCROLL_BASE_STEP = 1.2
|
||||||
|
|
||||||
@@ -394,12 +410,104 @@ const sectionHeightStyle = (height) => {
|
|||||||
const formalPageHeightStyle = computed(() => sectionHeightStyle(data.value.formalPageHeight))
|
const formalPageHeightStyle = computed(() => sectionHeightStyle(data.value.formalPageHeight))
|
||||||
const pageHeightStyle = (page) => sectionHeightStyle(page.height)
|
const pageHeightStyle = (page) => sectionHeightStyle(page.height)
|
||||||
|
|
||||||
|
const normalizeImages = (images = []) => {
|
||||||
|
const seen = new Set()
|
||||||
|
return images
|
||||||
|
.filter((src) => typeof src === 'string' && src.trim())
|
||||||
|
.map((src) => src.trim())
|
||||||
|
.filter((src) => {
|
||||||
|
if (seen.has(src)) return false
|
||||||
|
seen.add(src)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAlbumImages = () => {
|
||||||
|
const images = [data.value.heroImg, data.value.endImg]
|
||||||
|
;(data.value.photoPages || []).forEach((page) => {
|
||||||
|
images.push(page.img1, page.img2)
|
||||||
|
if (Array.isArray(page.images)) images.push(...page.images)
|
||||||
|
})
|
||||||
|
return normalizeImages(images)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAlbumPreviewImages = async () => {
|
||||||
|
if (albumLoaded.value && albumPreviewImages.value.length) return
|
||||||
|
const fallback = localAlbumImages()
|
||||||
|
try {
|
||||||
|
const res = await getAlbumImages()
|
||||||
|
albumPreviewImages.value = normalizeImages([...(res.data || []), ...fallback])
|
||||||
|
} catch (e) {
|
||||||
|
albumPreviewImages.value = fallback
|
||||||
|
}
|
||||||
|
albumLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreviewVisibleChange = (visible) => {
|
||||||
|
previewVisible.value = visible
|
||||||
|
if (visible) {
|
||||||
|
clearBottomReturn()
|
||||||
|
cancelAnimationFrame(animId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
freeScrollLastTs = 0
|
||||||
|
freeScrollCarry = 0
|
||||||
|
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumPreviewOptions = computed(() => ({
|
||||||
|
visible: previewVisible.value,
|
||||||
|
current: previewIndex.value,
|
||||||
|
onVisibleChange: handlePreviewVisibleChange,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const openImagePreview = async (src) => {
|
||||||
|
if (!src) return
|
||||||
|
await loadAlbumPreviewImages()
|
||||||
|
let images = albumPreviewImages.value.length ? albumPreviewImages.value : [src]
|
||||||
|
let index = images.indexOf(src)
|
||||||
|
if (index < 0) {
|
||||||
|
images = [src, ...images]
|
||||||
|
albumPreviewImages.value = images
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
previewIndex.value = index
|
||||||
|
previewVisible.value = true
|
||||||
|
freeScrollCarry = 0
|
||||||
|
clearBottomReturn()
|
||||||
|
cancelAnimationFrame(animId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAutoScrollBlocked = () =>
|
||||||
|
isInteracting.value || isDrawerOpen.value || previewVisible.value || showMapOptions.value || showIntro.value
|
||||||
|
|
||||||
|
const effectsActive = computed(() => !isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showIntro.value)
|
||||||
|
const effectsPerformanceMode = computed(() => data.value.scrollMode === 'free' && isAutoScroll.value)
|
||||||
|
|
||||||
|
const updateScrollMetrics = () => {
|
||||||
|
const el = scrollContainerRef.value
|
||||||
|
if (!el) return 0
|
||||||
|
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||||
|
maxScrollTop.value = max
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleScrollMetricsUpdate = () => {
|
||||||
|
cancelAnimationFrame(metricsRaf)
|
||||||
|
metricsRaf = requestAnimationFrame(() => {
|
||||||
|
metricsRaf = null
|
||||||
|
updateScrollMetrics()
|
||||||
|
handleScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const closeDrawer = () => { isDrawerOpen.value = false }
|
const closeDrawer = () => { isDrawerOpen.value = false }
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = scrollContainerRef.value
|
const el = scrollContainerRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const max = el.scrollHeight - el.clientHeight
|
const max = maxScrollTop.value || updateScrollMetrics()
|
||||||
progress.value = max > 0 ? el.scrollTop / max : 0
|
progress.value = max > 0 ? Math.min(1, el.scrollTop / max) : 0
|
||||||
|
if (progressBarRef.value) progressBarRef.value.style.transform = `scaleX(${progress.value})`
|
||||||
if (isAtBottom()) scheduleBottomReturn()
|
if (isAtBottom()) scheduleBottomReturn()
|
||||||
else clearBottomReturn()
|
else clearBottomReturn()
|
||||||
}
|
}
|
||||||
@@ -417,7 +525,9 @@ const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('
|
|||||||
|
|
||||||
const isAtBottom = () => {
|
const isAtBottom = () => {
|
||||||
const el = scrollContainerRef.value
|
const el = scrollContainerRef.value
|
||||||
return !!el && el.scrollHeight - el.clientHeight - el.scrollTop <= 8
|
if (!el) return false
|
||||||
|
const max = maxScrollTop.value || updateScrollMetrics()
|
||||||
|
return max - el.scrollTop <= 8
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearBottomReturn = () => {
|
const clearBottomReturn = () => {
|
||||||
@@ -427,12 +537,12 @@ const clearBottomReturn = () => {
|
|||||||
|
|
||||||
const scheduleBottomReturn = (reset = false) => {
|
const scheduleBottomReturn = (reset = false) => {
|
||||||
const el = scrollContainerRef.value
|
const el = scrollContainerRef.value
|
||||||
if (!el || !isAtBottom() || !isAutoScroll.value) return
|
if (!el || !isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
|
||||||
if (bottomReturnTimer && !reset) return
|
if (bottomReturnTimer && !reset) return
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
bottomReturnTimer = setTimeout(() => {
|
bottomReturnTimer = setTimeout(() => {
|
||||||
bottomReturnTimer = null
|
bottomReturnTimer = null
|
||||||
if (!isAtBottom() || !isAutoScroll.value || isDrawerOpen.value) return
|
if (!isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
|
||||||
if (isInteracting.value) {
|
if (isInteracting.value) {
|
||||||
scheduleBottomReturn(true)
|
scheduleBottomReturn(true)
|
||||||
return
|
return
|
||||||
@@ -478,21 +588,31 @@ const startAutoScroll = () => {
|
|||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
rafId = null
|
rafId = null
|
||||||
if (data.value.scrollMode === 'snap') {
|
if (data.value.scrollMode === 'snap') {
|
||||||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
|
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
|
||||||
snapTimer = setInterval(() => {
|
snapTimer = setInterval(() => {
|
||||||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
|
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
|
||||||
}, 4500)
|
}, 4500)
|
||||||
} else {
|
} else {
|
||||||
freeScrollLastTs = 0
|
freeScrollLastTs = 0
|
||||||
|
freeScrollCarry = 0
|
||||||
const tickFreeScroll = (now) => {
|
const tickFreeScroll = (now) => {
|
||||||
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
|
const el = scrollContainerRef.value
|
||||||
|
const dt = freeScrollLastTs ? Math.min(24, now - freeScrollLastTs) : 16.7
|
||||||
freeScrollLastTs = now
|
freeScrollLastTs = now
|
||||||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
|
if (el && isAutoScroll.value && !isAutoScrollBlocked()) {
|
||||||
if (isAtBottom()) {
|
const max = maxScrollTop.value || updateScrollMetrics()
|
||||||
|
if (max - el.scrollTop <= 8) {
|
||||||
scheduleBottomReturn()
|
scheduleBottomReturn()
|
||||||
} else {
|
} else {
|
||||||
scrollContainerRef.value.scrollTop += FREE_SCROLL_BASE_STEP * (dt / freeInterval.value)
|
freeScrollCarry += FREE_SCROLL_BASE_STEP * (dt / freeInterval.value)
|
||||||
if (isAtBottom()) scheduleBottomReturn()
|
if (freeScrollCarry >= 0.35) {
|
||||||
|
const currentTop = el.scrollTop
|
||||||
|
const nextTop = Math.min(max, currentTop + freeScrollCarry)
|
||||||
|
el.scrollTop = nextTop
|
||||||
|
freeScrollCarry = Math.max(0, freeScrollCarry - (nextTop - currentTop))
|
||||||
|
if (freeScrollCarry > 4) freeScrollCarry = 0
|
||||||
|
if (max - nextTop <= 8) scheduleBottomReturn()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(tickFreeScroll)
|
rafId = requestAnimationFrame(tickFreeScroll)
|
||||||
@@ -600,6 +720,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) { /* 使用默认配置 */ }
|
} catch (e) { /* 使用默认配置 */ }
|
||||||
|
|
||||||
|
albumLoaded.value = false
|
||||||
|
albumPreviewImages.value = localAlbumImages()
|
||||||
|
await nextTick()
|
||||||
|
scheduleScrollMetricsUpdate()
|
||||||
|
window.addEventListener('resize', scheduleScrollMetricsUpdate, { passive: true })
|
||||||
|
|
||||||
if (data.value.introEnabled) showIntro.value = true
|
if (data.value.introEnabled) showIntro.value = true
|
||||||
|
|
||||||
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
|
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
|
||||||
@@ -614,18 +740,20 @@ onMounted(async () => {
|
|||||||
scheduleAutoScrollStart()
|
scheduleAutoScrollStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll()
|
scheduleScrollMetricsUpdate()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
cancelAnimationFrame(animId)
|
cancelAnimationFrame(animId)
|
||||||
|
cancelAnimationFrame(metricsRaf)
|
||||||
clearInterval(snapTimer)
|
clearInterval(snapTimer)
|
||||||
clearInterval(freeTimer)
|
clearInterval(freeTimer)
|
||||||
clearTimeout(pauseTimer)
|
clearTimeout(pauseTimer)
|
||||||
clearTimeout(introTimer)
|
clearTimeout(introTimer)
|
||||||
clearTimeout(scrollStartTimer)
|
clearTimeout(scrollStartTimer)
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
|
window.removeEventListener('resize', scheduleScrollMetricsUpdate)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(freeScrollSignature, restartAutoScroll)
|
watch(freeScrollSignature, restartAutoScroll)
|
||||||
@@ -637,6 +765,9 @@ watch(freeScrollSignature, restartAutoScroll)
|
|||||||
font-variant-numeric: lining-nums;
|
font-variant-numeric: lining-nums;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
}
|
}
|
||||||
|
.album-preview-registry {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.wrapper .scrollable-area-body {
|
.wrapper .scrollable-area-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
Reference in New Issue
Block a user