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)
  }
}

  

posted @ 2025-07-19 15:30  沁猿春  阅读(61)  评论(0)    收藏  举报