feat(components): 新增CoiImageViewer图片查看器组件
- 新增通用图片查看器组件,支持缩略图预览和大图查看功能 - 支持自定义缩略图尺寸和预览弹框标题 - 集成图片下载和链接复制功能 - 实现加载状态和错误处理机制 - 采用现代化UI设计,支持悬停效果和动画过渡
This commit is contained in:
parent
0f98692b25
commit
cb4ebc8c14
395
src/components/common/CoiImageViewer.vue
Normal file
395
src/components/common/CoiImageViewer.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user