vue3+ts 组件封装实现图片的预览功能
<template>
<Teleport to="body">
<div v-if="visible">
<!-- 触发查看器的内容 -->
<slot name="trigger" />
<!-- 全屏蒙版查看器 -->
<Transition name="viewer-fade">
<div
v-if="visible"
class="image-viewer-mask"
@click.stop="handleClickOutside"
>
<!-- 加载状态 -->
<div
v-if="loading"
class="loading-mask"
>
<ASpin
tip="加载中..."
size="large"
/>
</div>
<!-- 查看器内容 -->
<Transition
v-if="!loading"
name="viewer-zoom"
>
<div
v-if="visible"
class="viewer-container"
>
<!-- 媒体内容 -->
<!-- 图片展示 -->
<div
v-if="isImage"
class="media-container"
>
<TransitionGroup :name="transitionName">
<img
:key="currentFile.url"
:src="currentFile.url"
alt="查看的图片"
class="preview-image"
@load="handleMediaLoad"
@error="handleMediaError"
>
</TransitionGroup>
</div>
<!-- 视频播放 -->
<div
v-if="isVideo"
class="media-container"
>
<video
:src="currentFile.url"
controls
autoplay
class="preview-video"
@loadeddata="handleMediaLoad"
@error="handleMediaError"
/>
</div>
<!-- 错误状态 -->
<div
v-else
class="error-container"
>
<Alert
message="媒体加载失败"
description="无法显示该媒体文件"
type="error"
/>
</div>
<!-- 索引指示器 -->
<div
v-if="total > 1"
class="index-indicator"
>
{{ currentIndex + 1 }} / {{ total }}
</div>
</div>
</Transition>
<!-- 关闭按钮 -->
<div
class="close-btn"
@click="handleClose"
>
<CloseOutlined />
</div>
<!-- 导航按钮 -->
<div class="nav-buttons">
<div
v-if="hasPrev"
class="nav-btn"
@click="prevFile"
>
<ArrowLeftOutlined />
</div>
<div v-if="!hasPrev" />
<div
v-if="hasNext"
class="nav-btn"
@click="nextFile"
>
<ArrowRightOutlined />
</div>
</div>
</div>
</Transition>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { ref, computed, defineEmits, defineExpose, onMounted, onBeforeUnmount } from 'vue'
// 定义媒体文件类型
interface MediaFile {
url: string;
type: 'image' | 'video';
name?: string;
}
const emits = defineEmits(['close'])
// 状态管理
const visible = ref(false)
const currentIndex = ref(0)
const loading = ref(true)
const error = ref(false)
const files = ref<MediaFile[]>([])
const transitionDirection = ref<'next' | 'prev'>('next')
// 计算属性
const currentFile = computed(() => files.value[currentIndex.value] || null)
const total = computed(() => files.value.length)
const isImage = computed(() => currentFile.value?.type === 'image')
const isVideo = computed(() => currentFile.value?.type === 'video')
const hasPrev = computed(() => total.value > 1 && currentIndex.value > 0)
const hasNext = computed(() => total.value > 1 && currentIndex.value < total.value - 1)
const transitionName = computed(() => `slide-${transitionDirection.value}`)
// 打开查看器
const openViewer = (newFiles: MediaFile[], index = 0, animationDuration = 300) => {
if (newFiles.length === 0) return
files.value = newFiles
currentIndex.value = index
loading.value = false
error.value = false
visible.value = true
console.log('打开查看器', newFiles, index, animationDuration)
}
// 关闭查看器
const handleClose = () => {
visible.value = false
emits('close')
}
// 键盘事件处理
const handleKeydown = (e: KeyboardEvent) => {
if (!visible.value) return
if (e.key === 'ArrowLeft') {
prevFile()
} else if (e.key === 'ArrowRight') {
nextFile()
} else if (e.key === 'Escape') {
handleClose()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
})
// 切换到上一张
const prevFile = () => {
if (hasPrev.value) {
transitionDirection.value = 'prev'
error.value = false
currentIndex.value--
}
}
// 切换到下一张
const nextFile = () => {
if (hasNext.value) {
transitionDirection.value = 'next'
error.value = false
currentIndex.value++
}
}
// 处理媒体加载成功
const handleMediaLoad = () => {
loading.value = false
}
// 处理媒体加载失败
const handleMediaError = () => {
loading.value = false
error.value = true
}
// 点击蒙版空白处关闭
const handleClickOutside = (e: MouseEvent) => {
// const target = e.currentTarget as HTMLElement
// const viewerContainer = target ? target.querySelector('.viewer-container') : null
// if (!viewerContainer || !viewerContainer.contains(e.target as Node)) {
// handleClose()
// }
}
// 暴露方法供父组件调用
defineExpose({
openViewer,
})
</script>
<style scoped lang="less">
.image-viewer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
z-index: 99999;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
.loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
}
.viewer-container {
position: relative;
width: 88%;
height: 90%;
z-index: 99999;
.media-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
.preview-image,
.preview-video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
.error-container {
padding: 20px;
color: #fff;
text-align: center;
}
}
.index-indicator {
position: absolute;
bottom: 20px;
color: #fff;
font-size: 18px;
background-color: rgba(0, 0, 0, 0.5);
padding: 6px 12px;
border-radius: 20px;
}
.close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 24px;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
}
.nav-buttons {
position: absolute;
top: 50%;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 20px;
.nav-btn {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 28px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
}
}
/* 蒙版淡入淡出动画 */
.viewer-fade-enter-from,
.viewer-fade-leave-to {
opacity: 0;
}
.viewer-fade-enter-active,
.viewer-fade-leave-active {
transition: opacity 0.3s ease;
}
.viewer-zoom-enter-from {
transform: scale(0.95);
opacity: 0;
}
.viewer-zoom-leave-to {
transform: scale(1.05);
opacity: 0;
}
.viewer-zoom-enter-active,
.viewer-zoom-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* 图片左右切换动画 */
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: all 0.3s ease;
position: absolute;
}
/* 上一张(左箭头)动画 - 从左往右滑 */
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
}
/* 下一张(右箭头)动画 - 从右往左滑 */
.slide-next-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-next-leave-to {
transform: translateX(-100%);
opacity: 0;
}
</style>
封装的组件使用方法:
<ImageViewer ref="imageViewerRef" />
import ImageViewer from '@/views/components/imageViewer.vue' //引入组件
//打开组件
// 图片查看
const openImageViewer = (image: any, i: number) => {
const files: { url: string; type: string; }[] = []
image.forEach((img: any) => {
files.push({
url: img.url ? img.url : img, ------------------后期自己字段换就可以
type: img.type == '1' ? 'image' as const : 'video' as const, --------------- 判断图片还是视频
})
})
if (imageViewerRef.value) {
imageViewerRef.value.openViewer(files, i)
}
}

浙公网安备 33010602011771号