Files
opencloud/src/components/cloud/ImageDetailModal.vue
T
Mplan 55c90e8c60 style(ui): unify sky-atlas visual system
Centralize interactive component styles, adopt the fresh eucalyptus palette, and keep map controls square with hard shadows. Update project guidance and the OpenCloud style skill to match the implementation.
2026-07-25 02:41:27 +08:00

308 lines
8.9 KiB
Vue

<script setup lang="ts">
import { InfoCircle, X } from '@vicons/tabler'
import { NIcon } from 'naive-ui'
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
badgeLabel?: string
badgeClass?: string
panelWidth?: string
}>(), {
subtitle: '',
thumbnailUrl: null,
badgeLabel: '',
badgeClass: '',
panelWidth: '',
})
const emit = defineEmits<{
close: []
}>()
const infoOpen = 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 modalVisible = ref(false)
const closing = ref(false)
let closeTimer: number | null = null
const previousBodyOverflow = ref<string | null>(null)
const previousDocumentOverflow = ref<string | null>(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 = viewportWidth.value > 0
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
: 320
const maxImageHeight = viewportHeight.value > 0
? Math.max(viewportHeight.value * 0.86, 240)
: 360
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
}
})
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
}
function lockPageScroll() {
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
previousBodyOverflow.value = document.body.style.overflow
previousDocumentOverflow.value = document.documentElement.style.overflow
document.body.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
}
function unlockPageScroll() {
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
document.body.style.overflow = previousBodyOverflow.value
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
previousBodyOverflow.value = null
previousDocumentOverflow.value = null
}
watch(() => props.open, open => {
if (open) {
if (closeTimer !== null) {
window.clearTimeout(closeTimer)
closeTimer = null
}
closing.value = false
modalVisible.value = true
lockPageScroll()
infoOpen.value = false
resetImageState()
} else {
modalVisible.value = false
unlockPageScroll()
}
}, { immediate: true })
watch(() => props.imageUrl, () => {
resetImageState()
})
function close() {
if (closing.value) return
closing.value = true
modalVisible.value = false
if (typeof window === 'undefined') {
unlockPageScroll()
emit('close')
return
}
closeTimer = window.setTimeout(() => {
closeTimer = null
unlockPageScroll()
emit('close')
}, 180)
}
function toggleInfo() {
infoOpen.value = !infoOpen.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 (closeTimer !== null) {
window.clearTimeout(closeTimer)
}
unlockPageScroll()
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateViewport)
}
})
</script>
<template>
<Teleport to="body">
<Transition
appear
name="image-detail-modal"
>
<div
v-if="modalVisible"
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
@click="close"
>
<div
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
@click.stop
>
<button
type="button"
@click="close"
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 rounded-full [&_svg]:stroke-[2.5]"
aria-label="关闭大图"
title="关闭大图"
>
<NIcon size="22">
<X />
</NIcon>
</button>
<div
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
:style="imageStyle"
>
<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"
/>
<button
type="button"
@click="toggleInfo"
class="oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 rounded-full drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
>
<NIcon size="30">
<InfoCircle />
</NIcon>
</button>
<div
v-if="infoOpen"
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
>
<div class="border-b border-slate-200 px-5 py-4">
<div class="pr-8">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
<span
v-if="badgeLabel"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="badgeClass"
>
{{ badgeLabel }}
</span>
</div>
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
</div>
<button
type="button"
@click="infoOpen = false"
class="oc-icon-button oc-icon-button--small absolute right-3 top-3 rounded-full"
aria-label="隐藏详细信息"
title="隐藏详细信息"
>
✕
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
<slot />
</div>
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
<slot name="actions" />
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style>
.image-detail-modal-enter-active,
.image-detail-modal-leave-active {
transition: opacity 180ms ease;
}
.image-detail-modal-enter-active .image-detail-shell,
.image-detail-modal-leave-active .image-detail-shell {
transition:
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.image-detail-modal-enter-from,
.image-detail-modal-leave-to {
opacity: 0;
}
.image-detail-modal-enter-from .image-detail-shell,
.image-detail-modal-leave-to .image-detail-shell {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
.image-detail-close-button.image-detail-close-button {
border-radius: 9999px !important;
}
</style>