2025-12-09 12:49:28 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<view v-if="show" class="image-preview" @touchmove.stop.prevent>
|
|
|
|
|
|
<!-- 背景遮罩 -->
|
|
|
|
|
|
<view class="preview-backdrop" @click="handleClose" />
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 主图区域 -->
|
|
|
|
|
|
<swiper
|
|
|
|
|
|
class="preview-swiper"
|
|
|
|
|
|
:current="currentIndex"
|
|
|
|
|
|
:indicator-dots="false"
|
|
|
|
|
|
@change="onSwiperChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<swiper-item v-for="(image, index) in images" :key="index" class="swiper-item">
|
|
|
|
|
|
<movable-area class="movable-area">
|
|
|
|
|
|
<movable-view
|
|
|
|
|
|
class="movable-view"
|
|
|
|
|
|
direction="all"
|
|
|
|
|
|
:scale="true"
|
|
|
|
|
|
:scale-min="1"
|
|
|
|
|
|
:scale-max="4"
|
|
|
|
|
|
:scale-value="scale"
|
|
|
|
|
|
@scale="onScale"
|
|
|
|
|
|
>
|
|
|
|
|
|
<image
|
|
|
|
|
|
class="preview-image"
|
|
|
|
|
|
:src="image"
|
|
|
|
|
|
mode="aspectFit"
|
|
|
|
|
|
@error="onImageError"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</movable-view>
|
|
|
|
|
|
</movable-area>
|
|
|
|
|
|
</swiper-item>
|
|
|
|
|
|
</swiper>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 顶部工具栏 -->
|
|
|
|
|
|
<view class="top-bar">
|
|
|
|
|
|
<view class="close-btn" @click="handleClose">
|
|
|
|
|
|
<wd-icon name="close" size="40rpx" color="#fff" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<view class="page-indicator">
|
|
|
|
|
|
<text>{{ currentIndex + 1 }} / {{ images.length }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<view class="actions">
|
|
|
|
|
|
<view class="action-btn" @click="saveImage">
|
|
|
|
|
|
<wd-icon name="download" size="36rpx" color="#fff" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部缩略图 -->
|
|
|
|
|
|
<view v-if="images.length > 1" class="thumbnail-bar">
|
|
|
|
|
|
<scroll-view class="thumbnail-scroll" scroll-x>
|
|
|
|
|
|
<view
|
|
|
|
|
|
v-for="(image, index) in images"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
class="thumbnail-item"
|
|
|
|
|
|
:class="{ active: index === currentIndex }"
|
|
|
|
|
|
@click="goToImage(index)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<image :src="image" mode="aspectFill" class="thumbnail-image" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</scroll-view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部控制栏 -->
|
|
|
|
|
|
<view class="bottom-bar">
|
|
|
|
|
|
<!-- 缩放按钮 -->
|
|
|
|
|
|
<view class="zoom-controls">
|
|
|
|
|
|
<view class="zoom-btn" @click="zoomOut">
|
|
|
|
|
|
<wd-icon name="remove" size="36rpx" color="#fff" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<text class="zoom-text">{{ Math.round(scale * 100) }}%</text>
|
|
|
|
|
|
<view class="zoom-btn" @click="zoomIn">
|
|
|
|
|
|
<wd-icon name="add" size="36rpx" color="#fff" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 旋转按钮 -->
|
|
|
|
|
|
<view class="rotate-btn" @click="rotate">
|
|
|
|
|
|
<wd-icon name="refresh" size="36rpx" color="#fff" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<wd-toast />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, watch, nextTick } from 'vue'
|
|
|
|
|
|
import { useToast } from 'wot-design-uni'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
show: boolean
|
|
|
|
|
|
images: string[]
|
|
|
|
|
|
initialIndex?: number
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
close: []
|
|
|
|
|
|
'update:show': [value: boolean]
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
const currentIndex = ref(0)
|
|
|
|
|
|
const scale = ref(1)
|
|
|
|
|
|
const rotation = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
// 监听打开
|
|
|
|
|
|
watch(() => props.show, (val) => {
|
|
|
|
|
|
if (val) {
|
|
|
|
|
|
currentIndex.value = props.initialIndex || 0
|
|
|
|
|
|
scale.value = 1
|
|
|
|
|
|
rotation.value = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// swiper 切换
|
|
|
|
|
|
function onSwiperChange(e: any) {
|
|
|
|
|
|
currentIndex.value = e.detail.current
|
|
|
|
|
|
scale.value = 1 // 切换后重置缩放
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到指定图片
|
|
|
|
|
|
function goToImage(index: number) {
|
|
|
|
|
|
currentIndex.value = index
|
|
|
|
|
|
scale.value = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缩放回调
|
|
|
|
|
|
function onScale(e: any) {
|
|
|
|
|
|
scale.value = e.detail.scale
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 放大
|
|
|
|
|
|
function zoomIn() {
|
|
|
|
|
|
scale.value = Math.min(4, scale.value + 0.5)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缩小
|
|
|
|
|
|
function zoomOut() {
|
|
|
|
|
|
scale.value = Math.max(1, scale.value - 0.5)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 旋转 (仅显示提示,Uniapp 原生组件不支持旋转)
|
|
|
|
|
|
function rotate() {
|
|
|
|
|
|
toast.show('长按图片可保存')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存图片
|
|
|
|
|
|
async function saveImage() {
|
|
|
|
|
|
const currentImage = props.images[currentIndex.value]
|
|
|
|
|
|
if (!currentImage) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 先下载图片
|
|
|
|
|
|
const downloadResult = await new Promise<{ tempFilePath: string }>((resolve, reject) => {
|
|
|
|
|
|
uni.downloadFile({
|
|
|
|
|
|
url: currentImage,
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.statusCode === 200) {
|
|
|
|
|
|
resolve(res)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
reject(new Error('下载失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: reject
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 保存到相册
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
|
uni.saveImageToPhotosAlbum({
|
|
|
|
|
|
filePath: downloadResult.tempFilePath,
|
|
|
|
|
|
success: () => resolve(),
|
|
|
|
|
|
fail: reject
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
toast.success('已保存到相册')
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
if (e?.errMsg?.includes('auth deny')) {
|
|
|
|
|
|
toast.error('请授权访问相册')
|
|
|
|
|
|
// 可以打开设置页面
|
|
|
|
|
|
uni.openSetting({})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error('保存失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 图片加载错误
|
|
|
|
|
|
function onImageError() {
|
|
|
|
|
|
toast.error('图片加载失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
|
|
|
|
|
emit('close')
|
|
|
|
|
|
emit('update:show', false)
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.image-preview {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 10000;
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.preview-backdrop {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.preview-swiper {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.swiper-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.movable-area {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.movable-view {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.preview-image {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top-bar {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
padding-left: 24rpx;
|
|
|
|
|
|
padding-right: 24rpx;
|
|
|
|
|
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
|
|
|
|
|
|
z-index: 10;
|
2025-12-15 11:06:42 +08:00
|
|
|
|
|
|
|
|
|
|
/* #ifdef MP-WEIXIN */
|
|
|
|
|
|
height: calc(88rpx + var(--status-bar-height, 44px) + 88rpx);
|
|
|
|
|
|
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
|
|
|
|
|
|
/* #endif */
|
|
|
|
|
|
|
|
|
|
|
|
/* #ifndef MP-WEIXIN */
|
2026-07-08 08:18:02 +08:00
|
|
|
|
height: calc(88rpx + var(--status-bar-height, 20px));
|
|
|
|
|
|
padding-top: var(--status-bar-height, 20px);
|
2025-12-15 11:06:42 +08:00
|
|
|
|
/* #endif */
|
2025-12-09 12:49:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.close-btn,
|
|
|
|
|
|
.action-btn {
|
|
|
|
|
|
width: 72rpx;
|
|
|
|
|
|
height: 72rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
backdrop-filter: blur(8px);
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-indicator {
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 16rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.thumbnail-bar {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 200rpx;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
padding: 16rpx 0;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.thumbnail-scroll {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
padding: 0 24rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.thumbnail-item {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
width: 100rpx;
|
|
|
|
|
|
height: 100rpx;
|
|
|
|
|
|
margin-right: 16rpx;
|
|
|
|
|
|
border-radius: 12rpx;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
border: 4rpx solid transparent;
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
|
border-color: #fff;
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.thumbnail-image {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-bar {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
height: calc(120rpx + env(safe-area-inset-bottom));
|
|
|
|
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 60rpx;
|
|
|
|
|
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.zoom-controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 24rpx;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border-radius: 40rpx;
|
|
|
|
|
|
padding: 8rpx 24rpx;
|
|
|
|
|
|
backdrop-filter: blur(8px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.zoom-btn {
|
|
|
|
|
|
width: 56rpx;
|
|
|
|
|
|
height: 56rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.zoom-text {
|
|
|
|
|
|
font-size: 24rpx;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
min-width: 80rpx;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.rotate-btn {
|
|
|
|
|
|
width: 72rpx;
|
|
|
|
|
|
height: 72rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
backdrop-filter: blur(8px);
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|