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