feat: enhance cloud image management

This commit is contained in:
2026-05-22 10:34:35 +08:00
parent 7cdf07447c
commit 7e4ee3d699
11 changed files with 1468 additions and 216 deletions
+188 -23
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
open: boolean
imageUrl: string
thumbnailUrl?: string | null
imageAlt: string
title: string
subtitle?: string
@@ -12,9 +13,10 @@ const props = withDefaults(defineProps<{
panelWidth?: string
}>(), {
subtitle: '',
thumbnailUrl: null,
badgeLabel: '',
badgeClass: '',
panelWidth: '20rem',
panelWidth: '',
})
const emit = defineEmits<{
@@ -22,23 +24,112 @@ const emit = defineEmits<{
}>()
const collapsed = ref(false)
const imageLoaded = ref(false)
const previewLoaded = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
const isDesktop = computed(() => viewportWidth.value >= 1024)
function clampPanelWidth(width: number) {
return Math.min(Math.max(width, 240), 400)
}
function parsePanelWidth(width: string) {
const normalized = width.trim()
if (!normalized) return null
if (normalized.endsWith('px')) return Number.parseFloat(normalized)
if (normalized.endsWith('rem')) return Number.parseFloat(normalized) * 16
return null
}
function updateViewport() {
if (typeof window === 'undefined') return
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
}
const layout = computed(() => {
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
? naturalWidth.value / naturalHeight.value
: 4 / 3
const maxModalWidth = Math.max(viewportWidth.value * 0.92, 320)
const mobileImageMaxHeight = Math.max(viewportHeight.value * 0.48, 220)
const desktopImageMaxHeight = Math.max(viewportHeight.value * 0.8, 240)
if (!isDesktop.value) {
const imageWidth = Math.min(maxModalWidth, mobileImageMaxHeight * aspectRatio)
const imageHeight = imageWidth / aspectRatio
const panelHeight = collapsed.value ? 0 : Math.min(Math.max(imageHeight * 0.72, 180), viewportHeight.value * 0.34)
return {
imageWidth,
imageHeight,
panelWidth: imageWidth,
panelHeight,
}
}
const maxImageHeight = desktopImageMaxHeight
if (collapsed.value) {
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth: 0,
panelHeight: imageWidth / aspectRatio,
}
}
const fixedPanelWidth = parsePanelWidth(props.panelWidth)
let panelWidth = fixedPanelWidth ?? clampPanelWidth(Math.min(maxModalWidth * 0.28, 320))
let imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
if (!fixedPanelWidth) {
for (let index = 0; index < 4; index += 1) {
panelWidth = clampPanelWidth(imageWidth * 0.34)
imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
}
}
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth,
panelHeight: imageWidth / aspectRatio,
}
})
const rootStyle = computed(() => ({
'--detail-panel-width': props.panelWidth,
'--detail-panel-width': `${Math.round(layout.value.panelWidth)}px`,
'--detail-panel-height': `${Math.round(layout.value.panelHeight)}px`,
}) as Record<string, string>)
const imageClass = computed(() => {
return collapsed.value
? 'block h-full w-auto max-w-full object-contain lg:max-w-[92vw]'
: 'block h-full w-auto max-w-full object-contain lg:max-w-[calc(92vw-var(--detail-panel-width))]'
})
const imageStyle = computed(() => ({
width: `${Math.round(layout.value.imageWidth)}px`,
height: `${Math.round(layout.value.imageHeight)}px`,
}) as Record<string, string>)
function resetImageState() {
imageLoaded.value = false
previewLoaded.value = false
naturalWidth.value = 0
naturalHeight.value = 0
}
watch(() => props.open, open => {
if (open) {
collapsed.value = false
resetImageState()
}
})
watch(() => props.imageUrl, () => {
resetImageState()
})
function close() {
emit('close')
}
@@ -46,6 +137,37 @@ function close() {
function togglePanel() {
collapsed.value = !collapsed.value
}
function handleImageLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
imageLoaded.value = true
}
function handlePreviewLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
if (!naturalWidth.value || !naturalHeight.value) {
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
}
previewLoaded.value = true
}
onMounted(() => {
updateViewport()
if (typeof window !== 'undefined') {
window.addEventListener('resize', updateViewport)
}
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateViewport)
}
})
</script>
<template>
@@ -56,28 +178,67 @@ function togglePanel() {
@click="close"
>
<div
class="relative h-[80vh] w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl lg:flex"
class="relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
:class="isDesktop ? 'lg:flex lg:items-start' : 'flex flex-col'"
:style="rootStyle"
@click.stop
>
<div class="flex min-h-0 flex-1 items-center justify-center overflow-hidden lg:flex-none">
<img :src="imageUrl" :alt="imageAlt" :class="imageClass" />
</div>
<button
v-if="isDesktop"
type="button"
@click="togglePanel"
class="absolute top-1/2 z-30 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
:style="{ left: `${Math.round(layout.imageWidth)}px` }"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
</button>
<div
class="relative h-[38vh] transition-[width] duration-300 lg:h-full lg:shrink-0"
:class="collapsed ? 'lg:w-0' : 'lg:w-[var(--detail-panel-width)]'"
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-950 lg:flex-none"
:style="imageStyle"
>
<button
type="button"
@click="togglePanel"
class="absolute left-0 top-1/2 z-10 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
</button>
<div
v-if="!previewLoaded && !imageLoaded"
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
></div>
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
:alt="imageAlt"
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
@load="handlePreviewLoad"
/>
<img
:src="imageUrl"
:alt="imageAlt"
class="relative block h-full w-full object-contain transition-opacity duration-300"
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
@load="handleImageLoad"
/>
</div>
<button
v-if="!isDesktop"
type="button"
@click="togglePanel"
class="flex items-center justify-center gap-2 border-t border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span>{{ collapsed ? '展开详细信息' : '收起详细信息' }}</span>
<span class="text-base leading-none">{{ collapsed ? '▼' : '▲' }}</span>
</button>
<div
class="relative overflow-hidden transition-[width,height] duration-300"
:class="[
collapsed ? 'h-0 lg:w-0' : 'w-full lg:w-[var(--detail-panel-width)]',
isDesktop ? 'lg:h-[var(--detail-panel-height)] lg:shrink-0' : 'h-[var(--detail-panel-height)]',
]"
>
<div
class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0"
:class="collapsed ? 'lg:border-l-0' : 'lg:border-l'"
@@ -109,6 +270,10 @@ function togglePanel() {
<div class="flex-1 overflow-y-auto px-6 py-5">
<slot />
</div>
<div v-if="$slots.actions" class="border-t border-slate-200 px-6 py-4">
<slot name="actions" />
</div>
</div>
</div>
</div>