deotalandAi/apps/frontend/src/components/ImagePreviewModal/index.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>