user_handbook/docs/.vuepress/components/LongPicSplit.vue
2026-03-30 10:23:03 +08:00

422 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, computed } from 'vue'
import { SITE_BASE, CDN_BASE} from '../constants';
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, default: '长图' },
chunkHeight: { type: Number, default: 2000 },
// 【新增】可选:传入原图宽高以实现完美的无抖动占位
originalWidth: { type: Number, default: 0 },
originalHeight: { type: Number, default: 0 },
maxWidth: { type: [String, Number], default: '100%' }
})
const imageList = ref([])
const isLoading = ref(true)
// 预览层状态
const showViewer = ref(false)
const currentIndex = ref(0)
const viewImg = ref('')
// 缩放状态
const scale = ref(1)
const startScale = ref(1)
const startDistance = ref(0)
// 滑动状态
const startX = ref(0)
// 计算宽高比,用于 CSS aspect-ratio
const aspectRatioStyle = computed(() => {
if (props.originalWidth && props.originalHeight) {
return `${props.originalWidth} / ${props.originalHeight}`
}
return undefined
})
// 打开预览
const openView = (idx) => {
if (imageList.value.length === 0) return
currentIndex.value = idx
viewImg.value = imageList.value[idx]
showViewer.value = true
scale.value = 1
document.body.style.overflow = 'hidden'
}
// 关闭预览
const closeView = () => {
showViewer.value = false
document.body.style.overflow = ''
}
// 上一张
const prevImg = () => {
if (currentIndex.value > 0) {
currentIndex.value--
viewImg.value = imageList.value[currentIndex.value]
scale.value = 1
}
}
// 下一张
const nextImg = () => {
if (currentIndex.value < imageList.value.length - 1) {
currentIndex.value++
viewImg.value = imageList.value[currentIndex.value]
scale.value = 1
}
}
// 触摸开始
const onTouchStart = (e) => {
if (e.touches.length === 1) {
startX.value = e.touches[0].clientX
}
if (e.touches.length === 2) {
startDistance.value = getDistance(e.touches[0], e.touches[1])
startScale.value = scale.value
}
}
// 触摸移动
const onTouchMove = (e) => {
// 阻止默认滚动行为,确保缩放和滑动流畅
if (e.touches.length === 2 || (e.touches.length === 1 && scale.value > 1)) {
// e.preventDefault() 在 passive listener 中可能无效,但在 Vue 事件修饰符 .prevent 中有效
// 这里主要逻辑在模板中处理了 .prevent
}
if (e.touches.length === 2) {
const dist = getDistance(e.touches[0], e.touches[1])
// 限制缩放范围 1x - 5x
scale.value = Math.min(5, Math.max(1, startScale.value * (dist / startDistance.value)))
}
}
// 触摸结束
const onTouchEnd = (e) => {
if (e.changedTouches.length === 1) {
const diff = e.changedTouches[0].clientX - startX.value
// 滑动阈值 60px
if (Math.abs(diff) > 60) {
diff > 0 ? prevImg() : nextImg()
}
}
}
// 计算双指距离
const getDistance = (p1, p2) => {
return Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY)
}
// 🔥 最大宽度样式
const wrapperStyle = computed(() => {
let maxW = props.maxWidth
if (typeof maxW === 'number') maxW = maxW + 'px'
return {
aspectRatio: aspectRatioStyle.value,
maxWidth: maxW
}
})
// 🔥🔥🔥 核心修复:自动拼接 VuePress 本地图片路径
const getImageUrl = (url) => {
// 如果是网络图片直接返回
if (url.startsWith('http')) return url
// 如果是本地图片,自动拼接基准路径(解决 404
const base = SITE_BASE || '/'
return base + url.replace(/^\//, '')
}
// 切割长图
const sliceImage = () => {
isLoading.value = true
const img = new Image()
// 处理跨域问题,如果图片在同源可忽略,但加上更稳健
img.crossOrigin = 'anonymous'
img.src = getImageUrl(props.src)
img.onload = () => {
const { width, height } = img
const num = Math.ceil(height / props.chunkHeight)
const slices = []
for (let i = 0; i < num; i++) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const h = Math.min(props.chunkHeight, height - i * props.chunkHeight)
canvas.width = width
canvas.height = h
// 绘制切片
ctx.drawImage(img, 0, i * props.chunkHeight, width, h, 0, 0, width, h)
// 转为 base64
slices.push(canvas.toDataURL('image/jpeg', 0.9)) // 0.9 质量平衡体积与清晰度
}
imageList.value = slices
isLoading.value = false
}
img.onerror = () => {
console.error('图片加载失败:', props.src)
isLoading.value = false
}
// img.src = props.src
}
onMounted(() => {
sliceImage()
})
</script>
<template>
<div
class="long-pic-slice no-medium-zoom"
:style="wrapperStyle"
>
<!-- 加载中占位提示 -->
<div v-if="isLoading" class="loading-state">
<span class="loading-text">正在加载长图...</span>
</div>
<!-- 切片列表 -->
<img
v-for="(data, idx) in imageList"
:key="idx"
:src="data"
:alt="`${alt}-${idx}`"
class="slice-img no-zoom"
loading="eager"
@click="openView(idx)"
/>
<!-- 全屏预览层 -->
<!-- <Teleport to="body">-->
<!-- <transition name="viewer-fade">-->
<div v-if="showViewer" class="viewer" @click="closeView">
<!-- 左箭头 -->
<div
class="arrow arrow-left"
@click.stop="prevImg"
:class="{ disabled: currentIndex <= 0 }"
>
</div>
<!-- 图片容器 (防止点击穿透) -->
<div class="viewer-content">
<img
:src="viewImg"
class="viewer-img"
:style="{ transform: `scale(${scale})` }"
@touchstart.prevent="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend.prevent="onTouchEnd"
/>
</div>
<!-- 右箭头 -->
<div
class="arrow arrow-right"
@click.stop="nextImg"
:class="{ disabled: currentIndex >= imageList.length - 1 }"
>
</div>
<!-- 底部指示器 -->
<div class="indicator">
{{ currentIndex + 1 }} / {{ imageList.length }}
</div>
<!-- 关闭按钮 (可选) -->
<div class="close-btn" @click.stop="closeView">×</div>
</div>
<!-- </transition>-->
<!-- </Teleport>-->
</div>
</template>
<style scoped>
.long-pic-slice {
width: 100%;
max-width: 100%;
margin: 12px 0;
position: relative;
background-color: #f5f5f5; /* 加载时的背景色 */
/* 【核心修复】设置最小高度,防止锚点定位错误 */
/* 如果没有传入宽高比,至少占据 300px避免高度为 0 */
min-height: 300px;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
/* 加载状态样式 */
.loading-state {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 0;
color: #999;
font-size: 14px;
background: rgba(255,255,255,0.5);
}
.slice-img {
display: block;
width: 100%;
height: auto;
cursor: pointer;
position: relative;
z-index: 1;
/* 移除图片底部的微小间隙 */
vertical-align: bottom;
}
/* --- 全屏预览样式 --- */
.viewer {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
backdrop-filter: blur(5px);
}
.viewer-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden; /* 防止放大后出现滚动条 */
}
.viewer-img {
max-width: 90%;
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out; /* 缩放过渡更跟手 */
user-select: none;
-webkit-user-drag: none;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
/* 箭头样式 */
.arrow {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 100;
user-select: none;
transition: all 0.2s;
border: 1px solid rgba(255,255,255,0.1);
}
.arrow:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-50%) scale(1.1);
}
.arrow-left { left: 20px; }
.arrow-right { right: 20px; }
.arrow.disabled {
opacity: 0.2;
cursor: not-allowed;
pointer-events: none;
}
/* 指示器 */
.indicator {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: 14px;
background: rgba(0, 0, 0, 0.6);
padding: 6px 16px;
border-radius: 20px;
z-index: 100;
font-family: monospace;
letter-spacing: 1px;
}
/* 关闭按钮 */
.close-btn {
position: fixed;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
background: rgba(0,0,0,0.2);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
z-index: 100;
transition: background 0.2s;
}
.close-btn:hover {
background: rgba(255,255,255,0.4);
}
/* 淡入淡出动画 */
.viewer-fade-enter-active,
.viewer-fade-leave-active {
transition: opacity 0.3s ease;
}
.viewer-fade-enter-from,
.viewer-fade-leave-to {
opacity: 0;
}
/* 移动端适配 */
@media (max-width: 768px) {
.arrow {
width: 36px;
height: 36px;
font-size: 24px;
}
.arrow-left { left: 10px; }
.arrow-right { right: 10px; }
.viewer-img {
max-width: 100%;
max-height: 100vh;
}
}
</style>