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
显式控制; - 行为:当
canEdit
为true
且存在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.userInfo
的userType
或userId
是否满足规则,以及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 项目进行使用。