701 lines
16 KiB
Vue
701 lines
16 KiB
Vue
<template>
|
|
<transition name="fade">
|
|
<div v-if="visible" class="image-preview-modal" @click.self="handleClose">
|
|
<!-- 顶部工具栏 -->
|
|
<div class="toolbar">
|
|
<div class="toolbar-left">
|
|
<div class="image-info">{{ imageIndex + 1 }} / {{ images.length }}</div>
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<button class="tool-btn" @click="handleRotate" title="旋转">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
<!-- <button class="tool-btn" @click="handleDownload" title="下载">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor"/>
|
|
</svg>
|
|
</button> -->
|
|
<button class="tool-btn close-btn" @click="handleClose" title="关闭">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主内容区域 -->
|
|
<div class="preview-container" ref="containerRef">
|
|
<!-- 左侧切换按钮 -->
|
|
<button
|
|
v-if="images.length > 1"
|
|
class="nav-btn prev-btn"
|
|
@click="prevImage"
|
|
:disabled="isFirstImage"
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- 右侧切换按钮 -->
|
|
<button
|
|
v-if="images.length > 1"
|
|
class="nav-btn next-btn"
|
|
@click="nextImage"
|
|
:disabled="isLastImage"
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- 图片容器 -->
|
|
<div
|
|
class="image-wrapper"
|
|
ref="imageWrapperRef"
|
|
@mousedown="handleMouseDown"
|
|
@mousemove="handleMouseMove"
|
|
@mouseup="handleMouseUp"
|
|
@mouseleave="handleMouseUp"
|
|
@wheel="handleWheel"
|
|
@touchstart="handleTouchStart"
|
|
@touchmove="handleTouchMove"
|
|
@touchend="handleTouchEnd"
|
|
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
|
|
>
|
|
<img
|
|
ref="imageRef"
|
|
:src="currentImage"
|
|
:style="imageStyle"
|
|
@load="handleImageLoad"
|
|
@error="handleImageError"
|
|
draggable="false"
|
|
alt="预览图片"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 底部缩放控制 -->
|
|
<div class="zoom-controls">
|
|
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= minScale">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M19 13H5v-2h14v2z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
<div class="zoom-level">{{ Math.round(scale * 100) }}%</div>
|
|
<button class="zoom-btn" @click="zoomIn" :disabled="scale >= maxScale">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
<button class="zoom-btn" @click="resetZoom">Reset</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
|
|
|
// Props
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
images: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
initialIndex: {
|
|
type: Number,
|
|
default: 0
|
|
}
|
|
});
|
|
|
|
// Emits
|
|
const emit = defineEmits(['close']);
|
|
|
|
// 响应式数据
|
|
const imageIndex = ref(0);
|
|
const scale = ref(1);
|
|
const rotation = ref(0);
|
|
const position = ref({ x: 0, y: 0 });
|
|
const isDragging = ref(false);
|
|
const dragStart = ref({ x: 0, y: 0 });
|
|
const imageLoaded = ref(false);
|
|
|
|
// 缩放限制
|
|
const minScale = 0.1;
|
|
const maxScale = 5;
|
|
const zoomStep = 0.2;
|
|
|
|
// 引用
|
|
const containerRef = ref(null);
|
|
const imageWrapperRef = ref(null);
|
|
const imageRef = ref(null);
|
|
|
|
// 计算属性
|
|
const currentImage = computed(() => {
|
|
return props.images[imageIndex.value] || '';
|
|
});
|
|
|
|
const isFirstImage = computed(() => {
|
|
return imageIndex.value === 0;
|
|
});
|
|
|
|
const isLastImage = computed(() => {
|
|
return imageIndex.value === props.images.length - 1;
|
|
});
|
|
|
|
const imageStyle = computed(() => {
|
|
return {
|
|
transform: `scale(${scale.value}) rotate(${rotation.value}deg)`,
|
|
transition: isDragging.value ? 'none' : 'transform 0.3s ease',
|
|
maxWidth: 'none',
|
|
maxHeight: 'none'
|
|
};
|
|
});
|
|
|
|
// 监听visible变化
|
|
watch(() => props.visible, (newVal) => {
|
|
if (newVal) {
|
|
imageIndex.value = props.initialIndex;
|
|
resetTransform();
|
|
document.body.style.overflow = 'hidden';
|
|
nextTick(() => {
|
|
fitToScreen();
|
|
});
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
});
|
|
|
|
// 监听图片索引变化
|
|
watch(imageIndex, () => {
|
|
resetTransform();
|
|
imageLoaded.value = false;
|
|
});
|
|
|
|
// 重置变换
|
|
const resetTransform = () => {
|
|
scale.value = 1;
|
|
rotation.value = 0;
|
|
position.value = { x: 0, y: 0 };
|
|
};
|
|
|
|
// 适应屏幕
|
|
const fitToScreen = () => {
|
|
if (!imageRef.value || !containerRef.value) return;
|
|
|
|
nextTick(() => {
|
|
const imgWidth = imageRef.value.naturalWidth;
|
|
const imgHeight = imageRef.value.naturalHeight;
|
|
const containerWidth = containerRef.value.clientWidth;
|
|
const containerHeight = containerRef.value.clientHeight;
|
|
|
|
const imgRatio = imgWidth / imgHeight;
|
|
const containerRatio = containerWidth / containerHeight;
|
|
|
|
if (imgRatio > containerRatio) {
|
|
// 图片较宽,以宽度为准
|
|
scale.value = containerWidth / imgWidth * 0.9;
|
|
} else {
|
|
// 图片较高,以高度为准
|
|
scale.value = containerHeight / imgHeight * 0.9;
|
|
}
|
|
|
|
// 确保不超出最大缩放
|
|
scale.value = Math.min(scale.value, 1);
|
|
});
|
|
};
|
|
|
|
// 图片加载完成
|
|
const handleImageLoad = () => {
|
|
imageLoaded.value = true;
|
|
if (scale.value === 1) {
|
|
fitToScreen();
|
|
}
|
|
};
|
|
|
|
// 图片加载错误
|
|
const handleImageError = () => {
|
|
console.error('图片加载失败');
|
|
};
|
|
|
|
// 关闭弹窗
|
|
const handleClose = () => {
|
|
emit('close');
|
|
};
|
|
|
|
// 切换图片
|
|
const prevImage = () => {
|
|
if (!isFirstImage.value) {
|
|
imageIndex.value--;
|
|
}
|
|
};
|
|
|
|
const nextImage = () => {
|
|
if (!isLastImage.value) {
|
|
imageIndex.value++;
|
|
}
|
|
};
|
|
|
|
// 旋转图片
|
|
const handleRotate = () => {
|
|
rotation.value = (rotation.value + 90) % 360;
|
|
};
|
|
|
|
// 下载图片
|
|
const handleDownload = () => {
|
|
const link = document.createElement('a');
|
|
link.href = currentImage.value;
|
|
link.download = `image-${Date.now()}.jpg`;
|
|
link.click();
|
|
};
|
|
|
|
// 缩放功能
|
|
const zoomIn = () => {
|
|
if (scale.value < maxScale) {
|
|
scale.value = Math.min(scale.value + zoomStep, maxScale);
|
|
}
|
|
};
|
|
|
|
const zoomOut = () => {
|
|
if (scale.value > minScale) {
|
|
scale.value = Math.max(scale.value - zoomStep, minScale);
|
|
}
|
|
};
|
|
|
|
const resetZoom = () => {
|
|
resetTransform();
|
|
fitToScreen();
|
|
};
|
|
|
|
// 鼠标滚轮缩放
|
|
const handleWheel = (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|
const newScale = Math.min(Math.max(scale.value + delta, minScale), maxScale);
|
|
|
|
if (newScale !== scale.value) {
|
|
// 计算鼠标位置相对于容器的坐标
|
|
const rect = imageWrapperRef.value.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// 计算缩放前后的比例
|
|
const scaleRatio = newScale / scale.value;
|
|
|
|
// 调整位置,使鼠标位置成为缩放中心
|
|
position.value.x = x - (x - position.value.x) * scaleRatio;
|
|
position.value.y = y - (y - position.value.y) * scaleRatio;
|
|
|
|
scale.value = newScale;
|
|
}
|
|
};
|
|
|
|
// 拖拽功能
|
|
const handleMouseDown = (e) => {
|
|
if (e.button === 0) { // 左键
|
|
isDragging.value = true;
|
|
dragStart.value = {
|
|
x: e.clientX - position.value.x,
|
|
y: e.clientY - position.value.y
|
|
};
|
|
}
|
|
};
|
|
|
|
const handleMouseMove = (e) => {
|
|
if (isDragging.value) {
|
|
position.value = {
|
|
x: e.clientX - dragStart.value.x,
|
|
y: e.clientY - dragStart.value.y
|
|
};
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
isDragging.value = false;
|
|
};
|
|
|
|
// 触摸功能
|
|
const touchStartDistance = ref(0);
|
|
const touchStartScale = ref(1);
|
|
const lastTouchTime = ref(0);
|
|
|
|
const handleTouchStart = (e) => {
|
|
if (e.touches.length === 1) {
|
|
// 单指触摸,准备拖拽
|
|
isDragging.value = true;
|
|
dragStart.value = {
|
|
x: e.touches[0].clientX - position.value.x,
|
|
y: e.touches[0].clientY - position.value.y
|
|
};
|
|
lastTouchTime.value = Date.now();
|
|
} else if (e.touches.length === 2) {
|
|
// 双指触摸,准备缩放
|
|
isDragging.value = false;
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
touchStartDistance.value = Math.sqrt(dx * dx + dy * dy);
|
|
touchStartScale.value = scale.value;
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (e.touches.length === 1 && isDragging.value) {
|
|
// 单指拖拽
|
|
position.value = {
|
|
x: e.touches[0].clientX - dragStart.value.x,
|
|
y: e.touches[0].clientY - dragStart.value.y
|
|
};
|
|
} else if (e.touches.length === 2) {
|
|
// 双指缩放
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (touchStartDistance.value > 0) {
|
|
const newScale = Math.min(
|
|
Math.max(touchStartScale.value * (distance / touchStartDistance.value), minScale),
|
|
maxScale
|
|
);
|
|
|
|
if (newScale !== scale.value) {
|
|
// 计算中心点
|
|
const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
|
|
const rect = imageWrapperRef.value.getBoundingClientRect();
|
|
const x = centerX - rect.left;
|
|
const y = centerY - rect.top;
|
|
|
|
const scaleRatio = newScale / scale.value;
|
|
|
|
position.value.x = x - (x - position.value.x) * scaleRatio;
|
|
position.value.y = y - (y - position.value.y) * scaleRatio;
|
|
|
|
scale.value = newScale;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = (e) => {
|
|
if (e.touches.length === 0) {
|
|
isDragging.value = false;
|
|
|
|
// 检测双击
|
|
const currentTime = Date.now();
|
|
if (currentTime - lastTouchTime.value < 300) {
|
|
// 双击,重置或适应屏幕
|
|
if (scale.value === 1) {
|
|
fitToScreen();
|
|
} else {
|
|
resetZoom();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 键盘事件
|
|
const handleKeyDown = (e) => {
|
|
if (!props.visible) return;
|
|
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
handleClose();
|
|
break;
|
|
case 'ArrowLeft':
|
|
prevImage();
|
|
break;
|
|
case 'ArrowRight':
|
|
nextImage();
|
|
break;
|
|
case '+':
|
|
case '=':
|
|
zoomIn();
|
|
break;
|
|
case '-':
|
|
case '_':
|
|
zoomOut();
|
|
break;
|
|
}
|
|
};
|
|
|
|
// 生命周期
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
document.body.style.overflow = '';
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.image-preview-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.9);
|
|
z-index: 9999;
|
|
display: flex;
|
|
flex-direction: column;
|
|
color: #fff;
|
|
user-select: none;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 20px;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.toolbar-left {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.image-info {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.tool-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
padding: 8px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.tool-btn:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.tool-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
background-color: rgba(255, 59, 48, 0.8);
|
|
}
|
|
|
|
.preview-container {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.nav-btn {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 50px;
|
|
height: 50px;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
border: none;
|
|
border-radius: 50%;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.nav-btn:hover:not(:disabled) {
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.nav-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.prev-btn {
|
|
left: 20px;
|
|
}
|
|
|
|
.next-btn {
|
|
right: 20px;
|
|
}
|
|
|
|
.image-wrapper {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.image-wrapper img {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
transform-origin: center;
|
|
}
|
|
|
|
.zoom-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.zoom-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background-color 0.2s;
|
|
min-width: 40px;
|
|
height: 40px;
|
|
}
|
|
|
|
.zoom-btn:hover:not(:disabled) {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.zoom-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.zoom-level {
|
|
min-width: 60px;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* 过渡动画 */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.3s;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* 响应式设计 */
|
|
@media (max-width: 768px) {
|
|
.toolbar {
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.toolbar-right {
|
|
gap: 12px;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
}
|
|
|
|
.prev-btn {
|
|
left: 10px;
|
|
}
|
|
|
|
.next-btn {
|
|
right: 10px;
|
|
}
|
|
|
|
.zoom-controls {
|
|
padding: 12px;
|
|
gap: 12px;
|
|
}
|
|
|
|
.zoom-btn {
|
|
min-width: 36px;
|
|
height: 36px;
|
|
}
|
|
|
|
.zoom-level {
|
|
min-width: 50px;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.toolbar {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.image-info {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.tool-btn {
|
|
padding: 6px;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
}
|
|
|
|
.prev-btn {
|
|
left: 8px;
|
|
}
|
|
|
|
.next-btn {
|
|
right: 8px;
|
|
}
|
|
|
|
.zoom-controls {
|
|
padding: 8px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.zoom-btn {
|
|
min-width: 32px;
|
|
height: 32px;
|
|
}
|
|
|
|
.zoom-level {
|
|
min-width: 45px;
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
</style> |