Add image detail map and quick upload
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<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<{
|
||||
@@ -23,26 +25,18 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const collapsed = ref(false)
|
||||
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 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
|
||||
}
|
||||
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
|
||||
@@ -54,59 +48,20 @@ 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)
|
||||
}
|
||||
}
|
||||
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,
|
||||
panelWidth,
|
||||
panelHeight: imageWidth / aspectRatio,
|
||||
}
|
||||
})
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--detail-panel-width': `${Math.round(layout.value.panelWidth)}px`,
|
||||
'--detail-panel-height': `${Math.round(layout.value.panelHeight)}px`,
|
||||
}) as Record<string, string>)
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
width: `${Math.round(layout.value.imageWidth)}px`,
|
||||
height: `${Math.round(layout.value.imageHeight)}px`,
|
||||
@@ -119,23 +74,63 @@ function resetImageState() {
|
||||
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) {
|
||||
collapsed.value = false
|
||||
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() {
|
||||
emit('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 togglePanel() {
|
||||
collapsed.value = !collapsed.value
|
||||
function toggleInfo() {
|
||||
infoOpen.value = !infoOpen.value
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
@@ -164,6 +159,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (closeTimer !== null) {
|
||||
window.clearTimeout(closeTimer)
|
||||
}
|
||||
unlockPageScroll()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
}
|
||||
@@ -172,111 +171,137 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||
@click="close"
|
||||
<Transition
|
||||
appear
|
||||
name="image-detail-modal"
|
||||
>
|
||||
<div
|
||||
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
|
||||
v-if="modalVisible"
|
||||
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||
@click="close"
|
||||
>
|
||||
<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 flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-950 lg:flex-none"
|
||||
:style="imageStyle"
|
||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<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'"
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border border-white/30 bg-white/15 p-0 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-white/25"
|
||||
aria-label="关闭大图"
|
||||
title="关闭大图"
|
||||
>
|
||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-2xl 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>
|
||||
<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="absolute bottom-4 right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-transparent text-white drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-colors hover:text-sky-100"
|
||||
: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>
|
||||
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="infoOpen = false"
|
||||
class="absolute right-3 top-3 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="隐藏详细信息"
|
||||
title="隐藏详细信息"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<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 v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user