Refactor ImageViewer component for improved functionality and performance

- Simplified image preview logic by removing unused features such as zooming and transformations, focusing on a straightforward full-screen preview experience.
- Enhanced event handling for image navigation and touch interactions, improving user experience on mobile devices.
- Updated styles for better visual consistency and responsiveness across different screen sizes.
- Removed unnecessary imports and streamlined the code for better maintainability.
This commit is contained in:
AdingApkgg
2025-12-27 08:03:49 +08:00
parent ad63e05975
commit 22a05a35a7
8 changed files with 151 additions and 860 deletions

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<!-- 关键渲染路径优化 - 同步执行,防止任何视觉闪烁 -->
<script>

View File

@@ -1,25 +1,13 @@
<script setup lang="ts">
/**
* 图片预览组件
* 替代 @fancyapps/ui使用 Vue 原生实现
* 简单全屏图片预览组件
* 点击图片全屏预览,点击任意位置关闭
*/
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import {
X,
ChevronLeft,
ChevronRight,
ZoomIn,
ZoomOut,
RotateCw,
RotateCcw,
FlipHorizontal,
FlipVertical,
Maximize2,
Download,
} from 'lucide-vue-next'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
import { useImageViewer } from '@/composables/useImageViewer'
import { playButton, playSwipe, playTransitionUp, playTransitionDown } from '@/composables/useSound'
import { playSwipe, playTransitionUp, playTransitionDown } from '@/composables/useSound'
const {
isOpen,
@@ -27,56 +15,22 @@ const {
currentIndex,
currentImage,
hasMultiple,
transform,
close,
prev,
next,
goTo,
zoomIn,
zoomOut,
toggleZoom,
rotateCW,
rotateCCW,
flipHorizontal,
flipVertical,
resetTransform,
} = useImageViewer()
// 图片加载状态
const isLoading = ref(true)
const hasError = ref(false)
// 拖拽状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const lastTranslate = ref({ x: 0, y: 0 })
// 双指缩放状态
const initialPinchDistance = ref(0)
const initialScale = ref(1)
// 计算变换样式
const imageStyle = computed(() => {
const scaleX = transform.flipX ? -transform.scale : transform.scale
const scaleY = transform.flipY ? -transform.scale : transform.scale
return {
transform: `translate(${transform.translateX}px, ${transform.translateY}px) scale(${scaleX}, ${scaleY}) rotate(${transform.rotate}deg)`,
transition: isDragging.value ? 'none' : 'transform 0.3s ease-out',
cursor: transform.scale > 1 ? (isDragging.value ? 'grabbing' : 'grab') : 'default',
}
})
// 进入动画时播放音效
// 不声明 done 参数,让 Vue 自动等待 CSS 动画完成
// 进入动画播放音效
function onEnter() {
playTransitionUp()
}
// 监听图片切换
// 监听图片切换,重置加载状态
watch(currentIndex, () => {
isLoading.value = true
hasError.value = false
})
// 图片加载完成
@@ -84,12 +38,6 @@ function handleImageLoad() {
isLoading.value = false
}
// 图片加载失败
function handleImageError() {
isLoading.value = false
hasError.value = true
}
// 关闭预览
function handleClose() {
playTransitionDown()
@@ -97,732 +45,167 @@ function handleClose() {
}
// 上一张
function handlePrev() {
function handlePrev(e: Event) {
e.stopPropagation()
playSwipe()
prev()
}
// 下一张
function handleNext() {
playSwipe()
function handleNext(e: Event) {
e.stopPropagation()
next()
}
// 工具栏操作
function handleZoomIn() {
playButton()
zoomIn()
}
function handleZoomOut() {
playButton()
zoomOut()
}
function handleToggleZoom() {
playButton()
toggleZoom()
}
function handleRotateCW() {
playButton()
rotateCW()
}
function handleRotateCCW() {
playButton()
rotateCCW()
}
function handleFlipH() {
playButton()
flipHorizontal()
}
function handleFlipV() {
playButton()
flipVertical()
}
// 下载图片
function handleDownload() {
if (!currentImage.value) {
return
}
playButton()
const link = document.createElement('a')
link.href = currentImage.value.src
link.download = currentImage.value.caption || 'image'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 鼠标拖拽
function handleMouseDown(e: MouseEvent) {
if (transform.scale <= 1) {
return
}
isDragging.value = true
dragStart.value = { x: e.clientX, y: e.clientY }
lastTranslate.value = { x: transform.translateX, y: transform.translateY }
e.preventDefault()
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging.value) {
return
}
const deltaX = e.clientX - dragStart.value.x
const deltaY = e.clientY - dragStart.value.y
transform.translateX = lastTranslate.value.x + deltaX
transform.translateY = lastTranslate.value.y + deltaY
}
function handleMouseUp() {
isDragging.value = false
}
// 双击放大/还原
function handleDoubleClick(e: MouseEvent) {
e.preventDefault()
handleToggleZoom()
}
// 滚轮缩放
function handleWheel(e: globalThis.WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) {
transform.scale = Math.min(transform.scale * 1.1, 10)
} else {
transform.scale = Math.max(transform.scale / 1.1, 0.5)
}
}
// 触摸事件
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
// 双指缩放开始
initialPinchDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = transform.scale
} else if (e.touches.length === 1 && transform.scale > 1) {
// 单指拖拽开始
isDragging.value = true
dragStart.value = { x: e.touches[0].clientX, y: e.touches[0].clientY }
lastTranslate.value = { x: transform.translateX, y: transform.translateY }
}
}
function handleTouchMove(e: TouchEvent) {
if (e.touches.length === 2 && initialPinchDistance.value > 0) {
// 双指缩放
const distance = getDistance(e.touches[0], e.touches[1])
const scale = (distance / initialPinchDistance.value) * initialScale.value
transform.scale = Math.min(Math.max(scale, 0.5), 10)
e.preventDefault()
} else if (e.touches.length === 1 && isDragging.value) {
// 单指拖拽
const deltaX = e.touches[0].clientX - dragStart.value.x
const deltaY = e.touches[0].clientY - dragStart.value.y
transform.translateX = lastTranslate.value.x + deltaX
transform.translateY = lastTranslate.value.y + deltaY
e.preventDefault()
}
}
function handleTouchEnd() {
isDragging.value = false
initialPinchDistance.value = 0
}
// 计算两点距离
function getDistance(touch1: globalThis.Touch, touch2: globalThis.Touch): number {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
// 滑动切换
let touchStartX = 0
let touchStartY = 0
function handleSwipeStart(e: TouchEvent) {
if (transform.scale > 1) {
return
}
touchStartX = e.touches[0].clientX
touchStartY = e.touches[0].clientY
}
function handleSwipeEnd(e: TouchEvent) {
if (transform.scale > 1) {
return
}
const deltaX = e.changedTouches[0].clientX - touchStartX
const deltaY = e.changedTouches[0].clientY - touchStartY
// 水平滑动幅度大于垂直,且超过阈值
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
if (deltaX > 0) {
handlePrev()
} else {
handleNext()
}
}
playSwipe()
}
// 键盘事件
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) {
return
}
if (!isOpen.value) {return}
switch (e.key) {
case 'Escape':
handleClose()
break
case 'ArrowLeft':
handlePrev()
playSwipe()
prev()
break
case 'ArrowRight':
handleNext()
break
case '+':
case '=':
handleZoomIn()
break
case '-':
handleZoomOut()
break
case '0':
resetTransform()
break
case 'r':
handleRotateCW()
break
case 'R':
handleRotateCCW()
playSwipe()
next()
break
}
}
// 点击背景关闭
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose()
// 触摸滑动切换
let touchStartX = 0
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX
}
function handleTouchEnd(e: TouchEvent) {
const deltaX = e.changedTouches[0].clientX - touchStartX
if (Math.abs(deltaX) > 80) {
if (deltaX > 0) {
playSwipe()
prev()
} else {
playSwipe()
next()
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('mouseup', handleMouseUp)
window.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('mousemove', handleMouseMove)
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-250 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
@enter="onEnter"
>
<div
v-if="isOpen"
class="image-viewer-overlay"
@click="handleBackdropClick"
class="fixed inset-0 z-[99999] flex items-center justify-center bg-black/95 cursor-pointer select-none"
@click="handleClose"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<!-- 背景 -->
<div class="image-viewer-backdrop" />
<!-- 主容器 -->
<div class="image-viewer-container">
<!-- 顶部工具栏 -->
<div class="image-viewer-toolbar top">
<!-- 左侧图片信息 -->
<div class="toolbar-left">
<span v-if="hasMultiple" class="image-counter">
{{ currentIndex + 1 }} / {{ images.length }}
</span>
</div>
<!-- 右侧操作按钮 -->
<div class="toolbar-right">
<!-- 缩放按钮 -->
<button
class="toolbar-btn"
title="放大 (+)"
@click="handleZoomIn"
>
<ZoomIn :size="20" />
</button>
<button
class="toolbar-btn"
title="缩小 (-)"
@click="handleZoomOut"
>
<ZoomOut :size="20" />
</button>
<button
class="toolbar-btn hidden-mobile"
title="1:1"
@click="handleToggleZoom"
>
<Maximize2 :size="20" />
</button>
<div class="toolbar-divider" />
<!-- 旋转翻转按钮 - 仅桌面端显示 -->
<button
class="toolbar-btn hidden-mobile"
title="逆时针旋转 (Shift+R)"
@click="handleRotateCCW"
>
<RotateCcw :size="20" />
</button>
<button
class="toolbar-btn hidden-mobile"
title="顺时针旋转 (R)"
@click="handleRotateCW"
>
<RotateCw :size="20" />
</button>
<button
class="toolbar-btn hidden-mobile"
title="水平翻转"
@click="handleFlipH"
>
<FlipHorizontal :size="20" />
</button>
<button
class="toolbar-btn hidden-mobile"
title="垂直翻转"
@click="handleFlipV"
>
<FlipVertical :size="20" />
</button>
<div class="toolbar-divider" />
<!-- 下载和关闭 -->
<button
class="toolbar-btn hidden-mobile"
title="下载"
@click="handleDownload"
>
<Download :size="20" />
</button>
<button
class="toolbar-btn close-btn"
title="关闭 (Esc)"
@click="handleClose"
>
<X :size="22" />
</button>
</div>
</div>
<!-- 图片容器 -->
<div
class="image-viewer-content"
@mousedown="handleMouseDown"
@wheel="handleWheel"
@dblclick="handleDoubleClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 加载指示器 -->
<div v-if="isLoading" class="image-loading">
<div class="loading-spinner" />
</div>
<!-- 错误提示 -->
<div v-else-if="hasError" class="image-error">
<span>图片加载失败</span>
</div>
<!-- 图片 -->
<img
v-show="!isLoading && !hasError && currentImage"
:src="currentImage?.src"
:alt="currentImage?.caption || ''"
:style="imageStyle"
class="viewer-image"
draggable="false"
@load="handleImageLoad"
@error="handleImageError"
@touchstart="handleSwipeStart"
@touchend="handleSwipeEnd"
/>
</div>
<!-- 左右切换按钮 -->
<template v-if="hasMultiple">
<button
class="nav-btn nav-prev"
title="上一张 (←)"
@click.stop="handlePrev"
>
<ChevronLeft :size="32" />
</button>
<button
class="nav-btn nav-next"
title="下一张 (→)"
@click.stop="handleNext"
>
<ChevronRight :size="32" />
</button>
</template>
<!-- 底部标题和缩略图 -->
<div class="image-viewer-toolbar bottom">
<!-- 标题 -->
<div v-if="currentImage?.caption" class="image-caption">
{{ currentImage.caption }}
</div>
<!-- 缩略图 -->
<div v-if="hasMultiple && images.length <= 20" class="thumbnails">
<button
v-for="(img, index) in images"
:key="index"
:class="['thumbnail', { active: index === currentIndex }]"
@click="goTo(index)"
>
<img :src="img.thumbnail || img.src" :alt="`缩略图 ${index + 1}`" />
</button>
</div>
</div>
<!-- 关闭按钮 -->
<button
class="absolute top-4 right-4 z-20 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
style="padding-top: max(0.5rem, env(safe-area-inset-top))"
@click.stop="handleClose"
>
<X :size="24" />
</button>
<!-- 图片计数 -->
<div
v-if="hasMultiple"
class="absolute top-4 left-4 z-20 px-3 py-1.5 rounded-full bg-white/10 text-white text-sm font-medium"
style="padding-top: max(0.5rem, env(safe-area-inset-top))"
>
{{ currentIndex + 1 }} / {{ images.length }}
</div>
<!-- 加载指示器 -->
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-10 h-10 border-3 border-white/20 border-t-pink-500 rounded-full animate-spin" />
</div>
<!-- 图片 -->
<img
v-show="currentImage"
:src="currentImage?.src"
:alt="currentImage?.caption || ''"
class="max-w-[95vw] max-h-[90vh] object-contain rounded-lg shadow-2xl transition-transform duration-200"
:class="{ 'scale-95 opacity-0': isLoading, 'scale-100 opacity-100': !isLoading }"
draggable="false"
@click.stop
@load="handleImageLoad"
/>
<!-- 图片标题 -->
<div
v-if="currentImage?.caption && !isLoading"
class="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 px-4 py-2 rounded-lg bg-black/60 text-white text-sm max-w-[80%] text-center"
style="padding-bottom: max(1.5rem, env(safe-area-inset-bottom))"
>
{{ currentImage.caption }}
</div>
<!-- 左右切换按钮 -->
<template v-if="hasMultiple">
<button
class="absolute left-2 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-white/10 hover:bg-white/25 text-white transition-all hover:scale-110"
@click="handlePrev"
>
<ChevronLeft :size="28" />
</button>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-white/10 hover:bg-white/25 text-white transition-all hover:scale-110"
@click="handleNext"
>
<ChevronRight :size="28" />
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.image-viewer-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.image-viewer-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.95);
}
.image-viewer-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* 工具栏 */
.image-viewer-toolbar {
position: absolute;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent);
}
.image-viewer-toolbar.top {
top: 0;
padding-top: max(12px, env(safe-area-inset-top));
}
.image-viewer-toolbar.bottom {
bottom: 0;
flex-direction: column;
gap: 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.image-counter {
color: white;
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.close-btn {
background: rgba(239, 68, 68, 0.3);
}
.toolbar-btn.close-btn:hover {
background: rgba(239, 68, 68, 0.5);
}
.toolbar-divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
/* 图片容器 */
.image-viewer-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 60px 0;
}
.viewer-image {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
}
/* 加载指示器 */
.image-loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #ff1493;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* 加载动画 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 错误提示 */
.image-error {
color: rgba(255, 255, 255, 0.6);
font-size: 16px;
}
/* 导航按钮 */
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 80px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-prev {
left: 0;
border-radius: 0 12px 12px 0;
}
.nav-next {
right: 0;
border-radius: 12px 0 0 12px;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.25);
width: 56px;
}
.nav-btn:active {
background: rgba(255, 255, 255, 0.35);
}
/* 标题 */
.image-caption {
color: white;
font-size: 15px;
text-align: center;
max-width: 80%;
padding: 6px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
}
/* 缩略图 */
.thumbnails {
display: flex;
gap: 8px;
padding: 8px;
max-width: 90vw;
overflow-x: auto;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
}
.thumbnail {
flex-shrink: 0;
width: 60px;
height: 60px;
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(0, 0, 0, 0.3);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail:hover {
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.05);
}
.thumbnail.active {
border-color: #ff1493;
box-shadow: 0 0 12px rgba(255, 20, 147, 0.5);
.animate-spin {
animation: spin 0.8s linear infinite;
}
/* 移动端适配 */
@media (max-width: 768px) {
.image-viewer-toolbar {
padding: 8px 12px;
}
.image-viewer-toolbar.top {
padding-top: max(8px, env(safe-area-inset-top));
}
.toolbar-btn {
width: 36px;
height: 36px;
}
.toolbar-btn.hidden-mobile {
.absolute.left-2,
.absolute.right-2 {
display: none;
}
.toolbar-divider {
display: none;
}
.toolbar-right {
gap: 6px;
}
.image-counter {
font-size: 12px;
padding: 3px 10px;
}
.nav-btn {
width: 40px;
height: 60px;
}
.thumbnail {
width: 48px;
height: 48px;
}
.image-caption {
font-size: 13px;
max-width: 90%;
}
.image-viewer-content {
padding: 50px 0;
}
}
/* 隐藏滚动条但保持功能 */
.thumbnails::-webkit-scrollbar {
height: 4px;
}
.thumbnails::-webkit-scrollbar-track {
background: transparent;
}
.thumbnails::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
</style>

View File

@@ -95,10 +95,10 @@
<div v-if="platformData.items.length > platformData.displayedCount" class="load-more mt-6 flex justify-center">
<button
class="px-6 py-3 rounded-xl
bg-theme-primary/90 dark:bg-theme-accent/90 text-white font-bold
border border-white/20
bg-pink-500 hover:bg-pink-600 dark:bg-pink-600 dark:hover:bg-pink-500
text-white font-bold shadow-lg shadow-pink-500/30 dark:shadow-pink-600/30
hover:scale-105 active:scale-95
transition-transform duration-200
transition-all duration-200
flex items-center gap-2"
@click="loadMore(platformName)"
>

View File

@@ -28,7 +28,7 @@
</div>
<!-- 隐藏的 busuanzi 元素 busuanzi 脚本更新 -->
<div id="busuanzi_container_site_pv" class="hidden">
<div id="busuanzi_container_site_pv" style="display: none !important; visibility: hidden !important; position: absolute; left: -9999px;">
<span id="busuanzi_value_site_pv" />
<span id="busuanzi_value_site_uv" />
</div>
@@ -51,22 +51,13 @@
<Users :size="16" class="text-theme-primary dark:text-theme-accent" />
<span class="font-semibold text-gray-800 dark:text-slate-100">{{ statsStore.visitorStats.uv }}</span>
</div>
<!-- 搜索统计有搜索记录时显示 -->
<template v-if="statsStore.appStats.totalSearches > 0">
<div class="h-px bg-gray-300/50 dark:bg-slate-600/50" />
<div class="flex items-center gap-2" title="本次会话搜索次数">
<Search :size="16" class="text-theme-primary dark:text-theme-accent" />
<span class="font-semibold text-gray-800 dark:text-slate-100">{{ statsStore.appStats.totalSearches }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Eye, Users, Activity, Wifi, WifiOff, Search } from 'lucide-vue-next'
import { Eye, Users, Activity, Wifi, WifiOff } from 'lucide-vue-next'
import { useStatsStore } from '@/stores/stats'
const statsStore = useStatsStore()

View File

@@ -97,7 +97,6 @@
:alt="searchStore.vndbInfo.mainName"
class="w-full h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 hover:scale-[1.02] transition-all"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleImageError"
/>
</button>
@@ -378,7 +377,6 @@
:alt="char.name"
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@load="($event.target as HTMLElement).parentElement?.querySelector('.skeleton')?.classList.add('hidden')"
/>
</template>
@@ -536,7 +534,6 @@
:alt="`${searchStore.vndbInfo.mainName} 截图 ${index + 1}`"
class="w-full h-auto cursor-pointer group-hover:scale-105 transition-transform duration-300"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleImageError"
/>
</button>

View File

@@ -1,14 +1,13 @@
/**
* 图片预览 Composable
* 管理图片预览状态和交互
* 管理简单全屏图片预览状态
*/
import { ref, computed, reactive } from 'vue'
import { ref, computed } from 'vue'
export interface ImageItem {
src: string
caption?: string
thumbnail?: string
}
// 全局状态
@@ -16,38 +15,17 @@ const isOpen = ref(false)
const images = ref<ImageItem[]>([])
const currentIndex = ref(0)
// 变换状态
const transform = reactive({
scale: 1,
rotate: 0,
flipX: false,
flipY: false,
translateX: 0,
translateY: 0,
})
// 当前图片
const currentImage = computed(() => images.value[currentIndex.value])
// 是否有多张图片
const hasMultiple = computed(() => images.value.length > 1)
// 重置变换
function resetTransform() {
transform.scale = 1
transform.rotate = 0
transform.flipX = false
transform.flipY = false
transform.translateX = 0
transform.translateY = 0
}
// 打开预览
function open(items: ImageItem | ImageItem[], startIndex = 0) {
const itemsArray = Array.isArray(items) ? items : [items]
images.value = itemsArray
currentIndex.value = Math.min(startIndex, itemsArray.length - 1)
resetTransform()
isOpen.value = true
// 禁止背景滚动
document.body.style.overflow = 'hidden'
@@ -61,75 +39,18 @@ function close() {
// 上一张
function prev() {
if (images.value.length <= 1) {
return
}
if (images.value.length <= 1) {return}
currentIndex.value = currentIndex.value === 0
? images.value.length - 1
: currentIndex.value - 1
resetTransform()
}
// 下一张
function next() {
if (images.value.length <= 1) {
return
}
if (images.value.length <= 1) {return}
currentIndex.value = currentIndex.value === images.value.length - 1
? 0
: currentIndex.value + 1
resetTransform()
}
// 跳转到指定图片
function goTo(index: number) {
if (index >= 0 && index < images.value.length) {
currentIndex.value = index
resetTransform()
}
}
// 缩放
function zoomIn() {
transform.scale = Math.min(transform.scale * 1.5, 10)
}
function zoomOut() {
transform.scale = Math.max(transform.scale / 1.5, 0.1)
}
function toggleZoom() {
if (transform.scale === 1) {
transform.scale = 2
} else {
transform.scale = 1
transform.translateX = 0
transform.translateY = 0
}
}
// 旋转
function rotateCW() {
transform.rotate += 90
}
function rotateCCW() {
transform.rotate -= 90
}
// 翻转
function flipHorizontal() {
transform.flipX = !transform.flipX
}
function flipVertical() {
transform.flipY = !transform.flipY
}
// 平移
function pan(deltaX: number, deltaY: number) {
transform.translateX += deltaX
transform.translateY += deltaY
}
export function useImageViewer() {
@@ -140,22 +61,10 @@ export function useImageViewer() {
currentIndex,
currentImage,
hasMultiple,
transform,
// 方法
open,
close,
prev,
next,
goTo,
zoomIn,
zoomOut,
toggleZoom,
rotateCW,
rotateCCW,
flipHorizontal,
flipVertical,
pan,
resetTransform,
}
}

View File

@@ -62,9 +62,6 @@ function createElements() {
position: absolute;
top: 0;
left: 0;
box-shadow:
0 0 10px rgba(255, 20, 147, 0.7),
0 0 20px rgba(217, 70, 239, 0.5);
transition: width 0.3s ease-out, opacity 0.2s ease;
}
@@ -80,7 +77,6 @@ function createElements() {
top: 0;
width: 100px;
height: 3px;
box-shadow: 0 0 10px #ff1493, 0 0 5px #ff1493;
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
@@ -111,9 +107,7 @@ function createElements() {
/* 暗色模式 */
.dark #progress-bar .progress-bar {
box-shadow:
0 0 15px rgba(255, 20, 147, 0.9),
0 0 30px rgba(217, 70, 239, 0.7);
/* 无额外阴影 */
}
.dark #progress-bar .spinner-icon {

View File

@@ -358,23 +358,41 @@ export function piniaSyncTabs(context: PiniaPluginContext) {
// 只对配置了 syncTabs 的 store 启用
if (!(options as { syncTabs?: boolean }).syncTabs) {return}
const channelName = `pinia-sync-${store.$id}`
// 创建广播频道
const channel = new BroadcastChannel(channelName)
// 监听其他标签页的状态变化
channel.onmessage = (event) => {
if (event.data.type === 'state-update') {
store.$patch(event.data.state)
// 检查浏览器是否支持 BroadcastChannel
if (typeof BroadcastChannel === 'undefined') {
if (import.meta.env.DEV) {
console.warn(`[pinia-sync-tabs] BroadcastChannel not supported, skipping sync for store: ${store.$id}`)
}
return
}
// 当前标签页状态变化时广播
store.$subscribe((_mutation, state) => {
channel.postMessage({
type: 'state-update',
state: JSON.parse(JSON.stringify(state)),
const channelName = `pinia-sync-${store.$id}`
try {
// 创建广播频道
const channel = new BroadcastChannel(channelName)
// 监听其他标签页的状态变化
channel.onmessage = (event) => {
if (event.data?.type === 'state-update') {
store.$patch(event.data.state)
}
}
// 当前标签页状态变化时广播
store.$subscribe((_mutation, state) => {
try {
channel.postMessage({
type: 'state-update',
state: JSON.parse(JSON.stringify(state)),
})
} catch {
// 静默处理序列化错误
}
})
})
} catch (error) {
if (import.meta.env.DEV) {
console.warn(`[pinia-sync-tabs] Failed to create BroadcastChannel for store: ${store.$id}`, error)
}
}
}