PhotoLightbox 组件设计与使用说明(独立版)

前言

在项目里,我们希望看图这件事是“安静”的:打开、沉浸、必要时操作。PhotoLightbox 就沿着这个方向去做——PC 端有拖拽/缩放,移动端保持轻量;权限内的人能一键去编辑,其余时间它只做一件事:把图好好地展示出来。


组件概览

  • 名称:PhotoLightbox
  • 作用:沉浸式看图 + PC 端拖拽缩放 + 可选编辑入口;
  • 适配范围:可直接拷贝至任意 Vue 3 项目,无需路由与 Sass 变量;
  • 一句话:看图稳、交互轻、把复杂度关在该关的地方;

设计思路

  • 完整图片优先:object-fit: contain,信息完整比“铺满”更重要;
  • 设备差异化:PC 端提供拖拽与滚轮缩放;移动端回到“看完即走”;
  • 权限内操作:只有管理员/作者能看到“编辑图片”,不会打扰普通浏览;
  • 导航双形态:PC 底部独立悬浮;移动端嵌在信息面板中,避免遮挡;
  • 轻提示不过度:首次可拖拽时出现 3 秒淡入淡出提示,点到为止;

功能实现(独立版 API)

Props / Emits

  • Props

    • show: boolean:是否显示灯箱
    • currentPhoto: object | null
      当前照片(建议包含 id、userId、imageUrl、title、description、location、date、photographer、category)
    • currentIndex: number:当前索引
    • totalCount: number:总数
    • showNavigation: boolean:是否显示导航
    • canEdit: boolean:是否显示“编辑图片”按钮(独立版新增,默认 false)
  • Emits

    • close / previous / next / edit

权限判定

  • 由外部通过 canEdit 显式控制;
  • 行为:当 canEdittrue 且存在 currentPhoto.id 时显示“编辑图片”按钮;
  • 交互:点击按钮触发 @edit 事件,由外部决定跳转/弹窗等处理;

拖拽与缩放(PC)

  • 启用条件:window.innerWidth > 768
  • 拖拽:鼠标按下抓取、移动时跟手、松开释放,指针在 grab/grabbing 之间切换;
  • 缩放:鼠标滚轮缩放,范围 0.5~3,简单直觉但不做锚点缩放;
  • 状态重置:切图或重新打开时重置位移与缩放,避免状态穿透;

错误占位

  • imageUrl 为空或加载错误时,显示占位图标 + 文案(“图片加载失败 / 图片URL为空”);
  • 切换时自动清理错误态,不让“坏状态”延续;

响应式与导航

  • PC:左右分栏(图片 2 / 信息 1),底部悬浮导航;
  • 移动端:单栏流式,导航放入信息面板内部,弱化干扰;

快速开始

  • 引入 /components/PhotoLightbox.vue
  • 准备图片数组与索引;
  • 维护外层可见态与换图逻辑;

使用示例

<template>
  <div>
    <button @click="visible = true">打开灯箱</button>

    <PhotoLightbox
      :show="visible"
      :currentPhoto="photos[currentIndex]"
      :currentIndex="currentIndex"
      :totalCount="photos.length"
      :showNavigation="true"
      :canEdit="userCanEdit(photos[currentIndex])"
      @close="visible = false"
      @previous="go(-1)"
      @next="go(1)"
      @edit="handleEdit"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import PhotoLightbox from '@/components/PhotoLightbox.vue'

const photos = ref([
  {
    id: 1,
    userId: 1001,
    imageUrl: 'https://example.com/a.jpg',
    title: '山色有无中',
    description: '雾起时的山谷',
    location: '大吴山',
    date: '2025-09-23',
    photographer: 'GanYu',
    category: '风光'
  },
  // ...
])

const currentUser = { userId: 1001, userType: 'admin' }

const visible = ref(false)
const currentIndex = ref(0)

function go(step) {
  const next = currentIndex.value + step
  if (next >= 0 && next < photos.value.length) currentIndex.value = next
}

function userCanEdit(photo) {
  return !!photo?.id && (currentUser.userType === 'admin' || currentUser.userType === 'super_admin' || currentUser.userId === photo.userId)
}

function handleEdit(photo) {
  // 无路由环境:自定义处理,比如弹窗或自定义跳转
  console.log('edit photo', photo)
}
</script>

要点:

  • 使用 canEdit 显式控制编辑按钮是否展示;点击触发 @edit
  • 不依赖路由,不依赖 localStorage
  • 索引越界由组件内禁用态兜底,索引更新仍由外层管理;

常见问题(Q&A)

  • 为什么移动端没有双指缩放?

    • 权衡后的选择:移动端优先“看图即走”,复杂手势易与系统手势冲突。
  • 切换图片时为什么缩放会被重置?

    • 每张图都是独立体验,避免“从上一张延续”的错觉。
  • 编辑按钮没出现?

    • 检查 localStorage.userInfouserTypeuserId 是否满足规则,以及 currentPhoto.id 是否存在。

可扩展建议

  • 支持 ESC 关闭与左右方向键切图,照顾桌面端用户的肌肉记忆;
  • 允许通过 props 设定默认缩放与偏移(审片时很有用);
  • 可选“以鼠标位置为中心”的锚点缩放,提升细节查看效率;
  • 增加“下载原图 / 复制图片链接”的快捷操作;
  • 将权限判断上提到调用方(通过 canEdit 显式传入),组件更“哑”;

总结

PhotoLightbox 更像一位“安静”的引导者:把注意力留给照片与故事。设计上收敛复杂度、减少打扰,交互上只在必要处发力(PC 的拖拽缩放、轻提示、权限内编辑),其余时候就让位给内容。

—— 用一句话概括:不炫技,稳体验。


完整代码:

PhotoLightbox.vue

<template>
  <div v-if="show" class="lb" @click="onClose">
    <div class="lb-c" @click.stop>
      <button class="lb-x" @click="onClose" aria-label="关闭">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
          <line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
          <line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
        </svg>
      </button>

      <div class="lb-img" ref="imageContainer">
        <img
          v-if="currentPhoto?.imageUrl && !imageError"
          :src="currentPhoto.imageUrl"
          :alt="currentPhoto.title || '图片'"
          @load="handleImageLoad"
          @error="handleImageError"
          class="lb-i"
          :class="{ 'drag': isDraggable }"
          :style="imageTransform"
          @mousedown="handleMouseDown"
          @mousemove="handleMouseMove"
          @mouseup="handleMouseUp"
          @mouseleave="handleMouseUp"
          @wheel="handleWheel"
          @touchstart="handleTouchStart"
          @touchmove="handleTouchMove"
          @touchend="handleTouchEnd"
          ref="draggableImage"
        />
        <div v-else class="lb-ph">
          <svg width="72" height="72" viewBox="0 0 24 24" fill="none">
            <rect x="3" y="3" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="1.5"/>
            <circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="1.5"/>
            <path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
          <p class="lb-err" v-if="imageError">图片加载失败</p>
          <p class="lb-err" v-else>图片URL为空</p>
        </div>

        <div v-if="isDraggable" class="lb-hint">
          <div class="lb-hc">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
              <path d="M8 6h8M8 12h8M8 18h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
            <span>拖动移动图片,滚轮缩放</span>
          </div>
        </div>
      </div>

      <div class="lb-info">
        <div v-if="canEditComputed" class="lb-edit">
          <button class="lb-eb" @click="$emit('edit', currentPhoto)">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
              <path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
            </svg>
            编辑图片
          </button>
        </div>

        <h2 class="lb-title">{{ currentPhoto?.title }}</h2>
        <p class="lb-desc">{{ currentPhoto?.description }}</p>

        <div class="lb-nav m" v-if="showNavigation" @click.stop>
          <button @click.stop="$emit('previous')" :disabled="currentIndex === 0" class="lb-btn">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
              <polyline points="15,18 9,12 15,6" stroke="currentColor" stroke-width="2"/>
            </svg>
            上一张
          </button>
          <span class="lb-count">{{ currentIndex + 1 }} / {{ totalCount }}</span>
          <button @click.stop="$emit('next')" :disabled="currentIndex === totalCount - 1" class="lb-btn">
            下一张
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
              <polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2"/>
            </svg>
          </button>
        </div>
      </div>

      <div class="lb-dets">
        <div class="lb-di"><span class="l">拍摄地点</span><span class="v">{{ currentPhoto?.location }}</span></div>
        <div class="lb-di"><span class="l">拍摄时间</span><span class="v">{{ currentPhoto?.date }}</span></div>
        <div class="lb-di"><span class="l">拍摄人</span><span class="v">{{ currentPhoto?.photographer }}</span></div>
        <div class="lb-di"><span class="l">分类</span><span class="v">{{ currentPhoto?.category }}</span></div>
      </div>
    </div>

    <div class="lb-nav d" v-if="showNavigation" @click.stop>
      <button @click.stop="$emit('previous')" :disabled="currentIndex === 0" class="lb-btn">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
          <polyline points="15,18 9,12 15,6" stroke="currentColor" stroke-width="2"/>
        </svg>
        上一张
      </button>
      <span class="lb-count">{{ currentIndex + 1 }} / {{ totalCount }}</span>
      <button @click.stop="$emit('next')" :disabled="currentIndex === totalCount - 1" class="lb-btn">
        下一张
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
          <polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2"/>
        </svg>
      </button>
    </div>
  </div>
</template>

<script>
import { ref, watch, computed } from 'vue'

export default {
  name: 'PhotoLightbox',
  props: {
    show: { type: Boolean, default: false },
    currentPhoto: { type: Object, default: null },
    currentIndex: { type: Number, default: 0 },
    totalCount: { type: Number, default: 0 },
    showNavigation: { type: Boolean, default: true },
    // 新增:由外部显式控制能否编辑,默认 false
    canEdit: { type: Boolean, default: false }
  },
  emits: ['close', 'previous', 'next', 'edit'],
  setup(props, { emit }) {
    const imageError = ref(false)

    // 拖动与缩放
    const isDragging = ref(false)
    const dragStart = ref({ x: 0, y: 0 })
    const imagePosition = ref({ x: 0, y: 0 })
    const imageScale = ref(1)
    const isDraggable = ref(false)
    const imageContainer = ref(null)
    const draggableImage = ref(null)

    const canEditComputed = computed(() => !!props.canEdit && !!props.currentPhoto?.id)

    const imageTransform = computed(() => ({
      transform: `translate(${imagePosition.value.x}px, ${imagePosition.value.y}px) scale(${imageScale.value})`,
      cursor: isDragging.value ? 'grabbing' : (isDraggable.value ? 'grab' : 'default'),
      objectFit: 'contain'
    }))

    watch(() => props.currentPhoto, () => {
      imageError.value = false
      resetImageTransform()
    })

    watch(() => props.show, (n) => {
      if (n) imageError.value = false
    })

    function handleImageLoad() {
      imageError.value = false
      setTimeout(checkIfDraggable, 100)
    }

    function handleImageError() {
      imageError.value = true
    }

    function resetImageTransform() {
      imagePosition.value = { x: 0, y: 0 }
      imageScale.value = 1
      isDraggable.value = false
    }

    function checkIfDraggable() {
      if (!draggableImage.value || !imageContainer.value) return
      isDraggable.value = window.innerWidth > 768
    }

    function handleMouseDown(e) {
      if (!isDraggable.value) return
      e.preventDefault()
      isDragging.value = true
      dragStart.value = { x: e.clientX - imagePosition.value.x, y: e.clientY - imagePosition.value.y }
    }

    function handleMouseMove(e) {
      if (!isDragging.value || !isDraggable.value) return
      e.preventDefault()
      imagePosition.value = { x: e.clientX - dragStart.value.x, y: e.clientY - dragStart.value.y }
    }

    function handleMouseUp() {
      isDragging.value = false
    }

    function handleWheel(e) {
      if (!isDraggable.value) return
      e.preventDefault()
      const delta = e.deltaY > 0 ? 0.9 : 1.1
      const next = Math.max(0.5, Math.min(3, imageScale.value * delta))
      if (next !== imageScale.value) imageScale.value = next
    }

    function handleTouchStart(e) {
      if (window.innerWidth <= 768) e.preventDefault()
    }

    function handleTouchMove(e) {
      if (window.innerWidth <= 768) e.preventDefault()
    }

    function handleTouchEnd(e) {
      if (window.innerWidth <= 768) e.preventDefault()
    }

    function onClose() {
      emit('close')
    }

    return {
      imageError,
      // 拖拽缩放
      isDragging,
      imagePosition,
      imageScale,
      isDraggable,
      imageTransform,
      imageContainer,
      draggableImage,
      // methods
      handleImageLoad,
      handleImageError,
      handleMouseDown,
      handleMouseMove,
      handleMouseUp,
      handleWheel,
      handleTouchStart,
      handleTouchMove,
      handleTouchEnd,
      resetImageTransform,
      onClose,
      // ui
      canEditComputed
    }
  }
}
</script>

<style scoped>
/* 基础令牌(纯 CSS 值,便于外部覆盖) */
:root { --lb-bg: rgba(0,0,0,.95); --lb-surface: #222; --lb-img-bg: #333; --lb-txt: #fff; --lb-muted: rgba(255,255,255,.8); --lb-error: #ff6b6b; --lb-radius: 16px; --lb-gap-xs: 6px; --lb-gap-sm: 8px; --lb-gap-md: 12px; --lb-gap-lg: 16px; --lb-gap-xl: 24px; --lb-gap-2xl: 32px; --lb-trans: .2s ease; }

.lb { position: fixed; inset: 0; background: var(--lb-bg); backdrop-filter: blur(20px); z-index: 9999; display: flex; align-items: center; justify-content: center; animation: lb-fade .3s ease; }
.lb-c { position: relative; width: 1020px; height: 638px; max-width: 90vw; max-height: 85vh; background: var(--lb-surface); border-radius: var(--lb-radius); overflow: hidden; display: grid; grid-template-columns: 2fr 1fr; transform: translateY(-5vh); }
.lb-x { position: absolute; top: var(--lb-gap-lg); right: var(--lb-gap-lg); background: rgba(0,0,0,.5); border: none; color: var(--lb-txt); width: 44px; height: 44px; border-radius: 999px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; transition: background var(--lb-trans); }
.lb-x:hover { background: rgba(0,0,0,.8); }

.lb-img { height: 100%; background: var(--lb-img-bg); display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.lb-i { width: 100%; height: 100%; object-fit: contain; object-position: center; transition: transform .1s ease-out; user-select: none; touch-action: none; }
.lb-i.drag { cursor: grab; }
.lb-i.drag:active { cursor: grabbing; }
.lb-ph { opacity: .35; text-align: center; color: var(--lb-txt); }
.lb-err { margin-top: var(--lb-gap-md); color: var(--lb-error); font-size: 12px; }
.lb-hint { position: absolute; top: var(--lb-gap-md); left: var(--lb-gap-md); background: rgba(0,0,0,.7); color: var(--lb-txt); padding: var(--lb-gap-sm) var(--lb-gap-md); border-radius: 8px; font-size: 12px; z-index: 10; animation: lb-fade-io 3s ease-in-out; pointer-events: none; }
.lb-hc { display: flex; align-items: center; gap: var(--lb-gap-xs); opacity: .9; }

.lb-info { padding: var(--lb-gap-2xl); padding-top: calc(var(--lb-gap-2xl) + 44px + var(--lb-gap-sm)); display: flex; flex-direction: column; position: relative; overflow-y: auto; max-height: 100%; color: var(--lb-txt); }
.lb-edit { position: absolute; top: var(--lb-gap-lg); left: var(--lb-gap-2xl); z-index: 10; display: flex; justify-content: flex-start; }
.lb-eb { display: flex; align-items: center; gap: var(--lb-gap-xs); background: rgba(99,102,241,.1); border: 1px solid rgba(99,102,241,.3); color: #818cf8; padding: var(--lb-gap-sm) var(--lb-gap-md); border-radius: 10px; font-size: 13px; font-weight: 500; cursor: pointer; transition: transform var(--lb-trans), background var(--lb-trans), border-color var(--lb-trans); height: 44px; min-width: 120px; }
.lb-eb:hover { background: rgba(99,102,241,.2); border-color: rgba(99,102,241,.5); transform: translateY(-1px); }
.lb-eb:active { transform: translateY(0); }
.lb-title { font-size: 20px; font-weight: 600; margin: 0 0 var(--lb-gap-sm) 0; }
.lb-desc { font-size: 14px; line-height: 1.6; opacity: .9; margin-bottom: var(--lb-gap-xl); }

.lb-dets { position: absolute; bottom: var(--lb-gap-2xl); left: calc(66.67% + var(--lb-gap-lg)); right: var(--lb-gap-2xl); display: flex; flex-direction: column; gap: var(--lb-gap-md); padding: var(--lb-gap-lg) 0; background: transparent; z-index: 10; color: var(--lb-txt); }
.lb-di { display: flex; justify-content: space-between; align-items: center; height: 40px; padding: 0; border-bottom: 1px solid rgba(255,255,255,.1); }
.lb-di .l { font-weight: 500; opacity: .7; margin-right: var(--lb-gap-md); }
.lb-di .v { font-weight: 600; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.lb-nav { position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 1020px; max-width: 90vw; background: transparent; padding: var(--lb-gap-lg) var(--lb-gap-xl); display: flex; justify-content: space-between; align-items: center; z-index: 10001; }
.lb-nav.m { display: none; position: static; transform: none; width: 100%; max-width: none; bottom: auto; margin-top: var(--lb-gap-lg); padding: var(--lb-gap-sm) var(--lb-gap-md); background: rgba(0,0,0,.1); border-radius: 12px; }
.lb-btn { background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.3); color: var(--lb-txt); padding: var(--lb-gap-md) var(--lb-gap-lg); border-radius: 12px; cursor: pointer; display: flex; align-items: center; gap: var(--lb-gap-sm); transition: background var(--lb-trans), border-color var(--lb-trans), opacity var(--lb-trans); pointer-events: auto; z-index: 10002; }
.lb-btn:hover:not(:disabled) { background: rgba(0,0,0,.5); border-color: rgba(255,255,255,.5); }
.lb-btn:disabled { opacity: .35; cursor: not-allowed; pointer-events: none; }
.lb-count { font-weight: 500; opacity: .8; color: var(--lb-txt); }

@keyframes lb-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lb-fade-io { 0% { opacity: 0; transform: translateY(-10px); } 20% { opacity: 1; transform: translateY(0); } 80% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-10px); } }

@media (max-width: 1024px) {
  .lb { align-items: center; padding: var(--lb-gap-md) 0 5px 0; overflow-y: auto; }
  .lb-c { grid-template-columns: 1fr; width: 95vw; height: auto; max-height: none; min-height: auto; transform: none; }
  .lb-x { top: var(--lb-gap-sm); right: var(--lb-gap-sm); width: 32px; height: 32px; }
  .lb-img { height: 50vh; min-height: 300px; }
  .lb-info { padding: var(--lb-gap-md); padding-top: calc(var(--lb-gap-md) + 36px + var(--lb-gap-sm)); padding-bottom: var(--lb-gap-md); overflow-y: visible; max-height: none; }
  .lb-title { font-size: 18px; margin-bottom: 4px; }
  .lb-desc { font-size: 13px; line-height: 1.4; margin-bottom: var(--lb-gap-md); }
  .lb-edit { top: var(--lb-gap-sm); left: var(--lb-gap-md); }
  .lb-eb { height: 36px; min-width: 100px; font-size: 12px; }
  .lb-dets { display: none; }
  .lb-nav.d { display: none; }
  .lb-nav.m { display: flex; }
  .lb-btn { padding: var(--lb-gap-xs) var(--lb-gap-sm); font-size: 12px; height: 32px; gap: var(--lb-gap-xs); }
}
</style>

使用原生 CSS 变量,可直接拷贝到任意 Vue3 项目进行使用。

最后修改:2025 年 09 月 23 日
给我一点小钱钱也很高兴啦!o(* ̄▽ ̄*)ブ