Industrial-image-management.../src/components/IndustrialImageList/index.vue

529 lines
13 KiB
Vue

<template>
<div class="industrial-image-list" :class="{ 'collapsed': isCollapsed }">
<div class="header-actions" v-if="!isCollapsed">
<slot name="header-left">
<a-button v-if="showImportButton" type="primary" @click="handleImportImages">
<template #icon><icon-upload /></template>
导入图像
</a-button>
</slot>
<div class="search-bar" v-if="showSearch">
<a-input-search
v-model="searchKeyword"
placeholder="输入关键字搜索"
allow-clear
@search="handleSearch"
@clear="handleSearchClear"
/>
</div>
<slot name="header-right"></slot>
<div class="collapse-button">
<a-button
type="text"
@click="toggleCollapse"
>
<template #icon>
<icon-up />
</template>
收起
</a-button>
</div>
</div>
<div class="image-grid" v-show="!isCollapsed">
<div v-if="imageList.length === 0" class="empty-data">
<icon-image class="empty-icon" />
<p>{{ emptyText }}</p>
</div>
<div v-else class="image-thumbnails">
<div
v-for="image in imageList"
:key="image.imageId"
class="thumbnail-item"
:class="{ active: selectedImageId === image.imageId }"
@click="handleImageSelect(image)"
>
<div class="thumbnail-image">
<img
:src="getImageUrl(image.imagePath)"
:alt="image.imageName"
@error="handleImageError"
@load="handleImageLoad"
/>
<div class="image-placeholder" v-if="!image.imagePath">
<icon-image />
<span>暂无图像</span>
</div>
<div class="thumbnail-overlay">
<div class="image-info">
<p class="image-name">{{ image.imageName }}</p>
<p class="image-size">{{ formatFileSize(image.size || 0) }}</p>
</div>
<div class="image-actions">
<a-button v-if="showPreviewAction" type="text" size="small" @click.stop="handleImagePreview(image)">
<icon-eye />
</a-button>
<a-button v-if="showProcessAction" type="text" size="small" @click.stop="handleImageProcess(image)">
<icon-settings />
</a-button>
<a-button v-if="showDeleteAction" type="text" size="small" status="danger" @click.stop="handleImageDelete(image)">
<icon-delete />
</a-button>
<slot name="item-actions" :image="image"></slot>
</div>
</div>
</div>
<div class="thumbnail-info">
<p class="thumbnail-name">{{ image.imageName }}</p>
<div class="thumbnail-meta">
<span class="image-type">{{ image.imageType?.toUpperCase() || 'IMAGE' }}</span>
<span v-if="image.defectCount" class="defect-count">缺陷: {{ image.defectCount }}</span>
<slot name="item-meta" :image="image"></slot>
</div>
<div class="thumbnail-extra" v-if="image.partName || image.shootingTime">
<span v-if="image.partName" class="part-name">{{ image.partName }}</span>
<span v-if="image.shootingTime" class="capture-time">{{ formatTime(image.shootingTime) }}</span>
</div>
<slot name="item-extra" :image="image"></slot>
</div>
</div>
</div>
</div>
<!-- 收起状态下的展开按钮 -->
<div v-if="isCollapsed" class="expand-button-container">
<a-button
type="primary"
@click="toggleCollapse"
>
<template #icon>
<icon-down />
</template>
展开图像列表
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
IconUpload,
IconImage,
IconEye,
IconSettings,
IconDelete,
IconUp,
IconDown
} from '@arco-design/web-vue/es/icon'
export interface IndustrialImage {
imageId: string
imageName: string
imagePath?: string
imageType?: string
size?: number
defectCount?: number
partName?: string
shootingTime?: string
[key: string]: any // 允许其他属性
}
const props = defineProps({
imageList: {
type: Array as () => IndustrialImage[],
default: () => []
},
selectedImageId: {
type: String,
default: ''
},
baseUrl: {
type: String,
default: 'http://pms.dtyx.net:9158'
},
emptyText: {
type: String,
default: '暂无图像数据'
},
showImportButton: {
type: Boolean,
default: true
},
showSearch: {
type: Boolean,
default: true
},
showPreviewAction: {
type: Boolean,
default: true
},
showProcessAction: {
type: Boolean,
default: true
},
showDeleteAction: {
type: Boolean,
default: true
}
})
const emit = defineEmits<{
importImages: []
imageSelect: [image: IndustrialImage]
imagePreview: [image: IndustrialImage]
imageProcess: [image: IndustrialImage]
imageDelete: [image: IndustrialImage]
search: [keyword: string]
collapsedChange: [collapsed: boolean]
}>()
const searchKeyword = ref('')
const isCollapsed = ref(false)
const handleImportImages = () => {
emit('importImages')
}
const handleImageSelect = (image: IndustrialImage) => {
emit('imageSelect', image)
}
const handleImagePreview = (image: IndustrialImage) => {
emit('imagePreview', image)
}
const handleImageProcess = (image: IndustrialImage) => {
emit('imageProcess', image)
}
const handleImageDelete = (image: IndustrialImage) => {
emit('imageDelete', image)
}
const handleSearch = () => {
emit('search', searchKeyword.value)
}
const handleSearchClear = () => {
searchKeyword.value = ''
emit('search', '')
}
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
emit('collapsedChange', isCollapsed.value)
}
// 图片加载处理
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'block'
}
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
console.warn('图片加载失败:', img.src)
}
// 获取完整的图片URL
const getImageUrl = (imagePath?: string): string => {
if (!imagePath) return ''
if (imagePath.startsWith('http')) return imagePath
return `${props.baseUrl}${imagePath}`
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// 格式化时间
const formatTime = (timeString: string): string => {
if (!timeString) return ''
try {
const date = new Date(timeString)
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return timeString
}
}
</script>
<style scoped lang="scss">
.industrial-image-list {
flex: 1;
display: flex;
flex-direction: column;
background: white;
height: 100%;
overflow: hidden;
transition: height 0.3s ease;
position: relative;
&.collapsed {
flex: 0 0 auto;
height: auto;
min-height: 0;
max-height: 0;
overflow: visible;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 16px;
justify-content: space-between;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s ease;
.search-bar {
width: 300px;
}
.collapse-button {
margin-left: auto;
}
}
/* 收起状态下的展开按钮容器 */
.expand-button-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}
.image-grid {
flex: 1;
overflow-y: auto;
padding: 16px;
height: calc(100% - 60px); /* 减去header-actions的高度 */
// 美化滚动条
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
&:hover {
background: #94a3b8;
}
}
.empty-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #6b7280;
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
p {
margin: 0;
font-size: 14px;
}
}
.image-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
.thumbnail-item {
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 8px 25px -8px rgba(0, 0, 0, 0.1);
}
&.active {
border-color: #3b82f6;
background: #f0f9ff;
}
.thumbnail-image {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
border-radius: 6px;
background: #f8f9fa;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.image-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f8f9fa;
color: #6b7280;
font-size: 12px;
.arco-icon {
font-size: 24px;
margin-bottom: 8px;
}
}
.thumbnail-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 12px;
color: white;
.image-info {
.image-name {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.image-size {
margin: 0;
font-size: 12px;
opacity: 0.9;
}
}
.image-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
.arco-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
backdrop-filter: blur(4px);
&:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
}
&.arco-btn-status-danger:hover {
background: rgba(239, 68, 68, 0.8);
}
}
}
}
&:hover .thumbnail-overlay {
opacity: 1;
}
}
.thumbnail-info {
padding: 8px;
.thumbnail-name {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thumbnail-meta {
display: flex;
gap: 8px;
align-items: center;
.image-type {
font-size: 12px;
color: #6b7280;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
}
.defect-count {
font-size: 12px;
color: #dc2626;
background: #fee2e2;
padding: 2px 6px;
border-radius: 4px;
}
}
.thumbnail-extra {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 2px;
.part-name {
font-size: 11px;
color: #059669;
background: #ecfdf5;
padding: 1px 4px;
border-radius: 3px;
align-self: flex-start;
}
.capture-time {
font-size: 10px;
color: #6b7280;
opacity: 0.8;
}
}
}
}
}
}
}
</style>