feat(components): 新增CoiImageViewer图片查看器组件

- 新增通用图片查看器组件,支持缩略图预览和大图查看功能
- 支持自定义缩略图尺寸和预览弹框标题
- 集成图片下载和链接复制功能
- 实现加载状态和错误处理机制
- 采用现代化UI设计,支持悬停效果和动画过渡
This commit is contained in:
Leo 2025-09-22 22:56:53 +08:00
parent 0f98692b25
commit cb4ebc8c14

View File

@ -0,0 +1,395 @@
<template>
<div class="coi-image-viewer">
<!-- 缩略图展示 -->
<div
class="thumbnail-container"
:style="{ width: `${width}px`, height: `${height}px` }"
@click="handlePreview"
>
<img
v-if="!imageError && src"
:src="src"
:alt="alt || 'image'"
class="thumbnail-image"
@load="handleImageLoad"
@error="handleImageError"
>
<div v-else class="error-placeholder">
<NIcon size="16" class="error-icon">
<IconParkOutlinePic />
</NIcon>
<span class="error-text">加载失败</span>
</div>
<!-- 悬停遮罩层 -->
<div class="hover-overlay">
<NIcon size="18" class="preview-icon">
<IconParkOutlinePreviewOpen />
</NIcon>
<span class="preview-text">点击预览</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<NSpin size="small" />
</div>
</div>
<!-- 大图预览弹框 -->
<NModal
v-model:show="previewVisible"
preset="card"
:title="modalTitle"
:style="{ width: '90%', maxWidth: '1200px' }"
:mask-closable="true"
:closable="true"
:auto-focus="false"
:trap-focus="false"
>
<div class="preview-container">
<img
v-if="!previewError"
:src="src"
:alt="alt || 'preview'"
class="preview-image"
@load="handlePreviewLoad"
@error="handlePreviewError"
>
<div v-else class="preview-error">
<NIcon size="48" class="error-icon">
<IconParkOutlinePic />
</NIcon>
<p class="error-message">
图片加载失败
</p>
<p class="error-url">
{{ src }}
</p>
</div>
<!-- 预览加载状态 -->
<div v-if="previewLoading" class="preview-loading">
<NSpin size="large" />
<p class="loading-text">
图片加载中...
</p>
</div>
</div>
<!-- 弹框底部操作 -->
<template #action>
<NSpace justify="space-between">
<NSpace>
<NButton size="small" @click="handleDownload">
<template #icon>
<NIcon><IconParkOutlineDownload /></NIcon>
</template>
下载图片
</NButton>
<NButton size="small" @click="handleCopyUrl">
<template #icon>
<NIcon><IconParkOutlineCopy /></NIcon>
</template>
复制链接
</NButton>
</NSpace>
<NButton type="primary" @click="previewVisible = false">
关闭
</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<script setup lang="ts">
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import IconParkOutlinePic from '~icons/icon-park-outline/pic'
import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
import IconParkOutlineDownload from '~icons/icon-park-outline/download'
import IconParkOutlineCopy from '~icons/icon-park-outline/copy'
interface Props {
/** 图片链接 */
src: string | undefined
/** 图片描述 */
alt?: string
/** 缩略图宽度 */
width?: number
/** 缩略图高度 */
height?: number
/** 是否可以预览 */
previewable?: boolean
/** 预览弹框标题 */
title?: string
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
width: 50,
height: 40,
previewable: true,
title: '图片预览',
})
//
const loading = ref(true)
const imageError = ref(false)
//
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewError = ref(false)
//
const modalTitle = computed(() => {
return props.title || props.alt || '图片预览'
})
//
function handleImageLoad() {
loading.value = false
imageError.value = false
}
//
function handleImageError() {
loading.value = false
imageError.value = true
}
//
function handlePreview() {
if (!props.previewable || imageError.value) {
return
}
previewVisible.value = true
previewLoading.value = true
previewError.value = false
}
//
function handlePreviewLoad() {
previewLoading.value = false
previewError.value = false
}
//
function handlePreviewError() {
previewLoading.value = false
previewError.value = true
}
//
async function handleDownload() {
try {
const link = document.createElement('a')
link.href = props.src
link.download = props.alt || 'image'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
coiMsgSuccess('下载已开始')
}
catch {
coiMsgError('下载失败,请重试')
}
}
//
async function handleCopyUrl() {
try {
await navigator.clipboard.writeText(props.src)
coiMsgSuccess('链接已复制到剪贴板')
}
catch {
coiMsgError('复制失败,请手动复制')
}
}
// src
watch(
() => props.src,
() => {
loading.value = true
imageError.value = false
previewError.value = false
},
{ immediate: true },
)
</script>
<style scoped>
.coi-image-viewer {
display: inline-block;
}
.thumbnail-container {
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: var(--card-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.thumbnail-container:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.thumbnail-container:hover .thumbnail-image {
transform: scale(1.05);
}
/* 当组件被设置为100%宽高时的样式调整 */
.coi-image-viewer.w-full.h-full .thumbnail-container {
width: 100% !important;
height: 100% !important;
border-radius: 0;
}
.error-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--card-color);
color: var(--text-color-3);
border: 1px dashed var(--border-color);
}
.error-icon {
margin-bottom: 4px;
color: var(--text-color-3);
}
.error-text {
font-size: 10px;
color: var(--text-color-3);
}
.hover-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
color: white;
}
.thumbnail-container:hover .hover-overlay {
opacity: 1;
}
.preview-icon {
margin-bottom: 4px;
}
.preview-text {
font-size: 10px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--card-color);
display: flex;
align-items: center;
justify-content: center;
}
.preview-container {
position: relative;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: var(--body-color);
border-radius: 8px;
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 4px;
}
.preview-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-color-3);
text-align: center;
padding: 40px 20px;
}
.error-message {
font-size: 16px;
margin: 16px 0 8px 0;
color: var(--text-color-2);
}
.error-url {
font-size: 12px;
color: var(--text-color-3);
word-break: break-all;
max-width: 500px;
}
.preview-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-text {
margin-top: 16px;
color: var(--text-color-2);
font-size: 14px;
}
/* 响应式适配 */
@media (max-width: 768px) {
.preview-container {
min-height: 200px;
}
.preview-image {
max-height: 50vh;
}
}
</style>