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.
308 lines
8.9 KiB
Vue
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>
|