692 lines
17 KiB
Vue
692 lines
17 KiB
Vue
<template>
|
||
<div class="image-import">
|
||
<a-modal
|
||
v-model:visible="visible"
|
||
title="导入图像"
|
||
width="600px"
|
||
:confirm-loading="importing"
|
||
@ok="handleImport"
|
||
@cancel="handleCancel"
|
||
>
|
||
<div class="import-content">
|
||
<!-- 选择项目和组件 -->
|
||
<a-form :model="form" layout="vertical">
|
||
<a-form-item label="图像来源" required>
|
||
<a-select
|
||
v-model="form.imageSource"
|
||
:options="imageSourceOptions"
|
||
placeholder="请选择图像来源"
|
||
:loading="loadingImageSources"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="目标项目" required>
|
||
<a-tree-select
|
||
v-model="form.projectId"
|
||
:data="projectTree"
|
||
:field-names="{ key: 'id', title: 'name', children: 'children' }"
|
||
placeholder="请选择项目"
|
||
tree-checkable
|
||
:tree-check-strictly="true"
|
||
@change="onProjectChange"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="目标组件">
|
||
<a-select
|
||
v-model="form.componentId"
|
||
:options="componentOptions"
|
||
placeholder="请选择组件(可选)"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="上传用户">
|
||
<a-input v-model="form.uploadUser" placeholder="请输入上传用户" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="位置信息">
|
||
<a-row :gutter="16">
|
||
<a-col :span="8">
|
||
<a-input v-model="form.altitude" placeholder="海拔" />
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-input v-model="form.latitude" placeholder="纬度" />
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-input v-model="form.longitude" placeholder="经度" />
|
||
</a-col>
|
||
</a-row>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="导入设置">
|
||
<a-checkbox-group v-model="form.settings">
|
||
<a-checkbox value="autoAnnotate">导入后自动标注</a-checkbox>
|
||
<a-checkbox value="overwrite">覆盖同名文件</a-checkbox>
|
||
</a-checkbox-group>
|
||
</a-form-item>
|
||
|
||
<a-form-item v-if="form.settings.includes('autoAnnotate')" label="标注类型">
|
||
<a-select
|
||
v-model="form.annotationTypes"
|
||
:options="defectTypeOptions"
|
||
placeholder="选择要自动标注的缺陷类型"
|
||
multiple
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
|
||
<!-- 文件上传区域 -->
|
||
<div class="upload-section">
|
||
<a-upload
|
||
ref="uploadRef"
|
||
:custom-request="() => {}"
|
||
:show-file-list="false"
|
||
accept="image/*"
|
||
multiple
|
||
@change="handleFileChange"
|
||
>
|
||
<template #upload-button>
|
||
<div class="upload-area">
|
||
<div class="upload-drag-icon">
|
||
<IconUpload size="48" />
|
||
</div>
|
||
<div class="upload-text">
|
||
<p>点击或拖拽图像文件到此区域</p>
|
||
<p class="upload-hint">支持 JPG、PNG、JPEG 格式,可同时选择多个文件</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</a-upload>
|
||
</div>
|
||
|
||
<!-- 文件列表 -->
|
||
<div v-if="fileList.length > 0" class="file-list">
|
||
<div class="list-header">
|
||
<h4>待导入文件 ({{ fileList.length }})</h4>
|
||
<a-button type="text" @click="clearFiles">
|
||
<template #icon>
|
||
<IconDelete />
|
||
</template>
|
||
清空
|
||
</a-button>
|
||
</div>
|
||
<div class="list-content">
|
||
<div
|
||
v-for="(file, index) in fileList"
|
||
:key="index"
|
||
class="file-item"
|
||
>
|
||
<div class="file-info">
|
||
<div class="file-preview">
|
||
<img :src="file.preview" alt="preview" />
|
||
</div>
|
||
<div class="file-details">
|
||
<div class="file-name">{{ file.name }}</div>
|
||
<div class="file-size">{{ formatFileSize(file.size) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="file-actions">
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
@click="removeFile(index)"
|
||
>
|
||
<template #icon>
|
||
<IconClose />
|
||
</template>
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 导入进度 -->
|
||
<div v-if="importing" class="import-progress">
|
||
<a-progress
|
||
:percent="importProgress"
|
||
:status="importStatus"
|
||
size="large"
|
||
/>
|
||
<p class="progress-text">{{ progressText }}</p>
|
||
</div>
|
||
|
||
<!-- 导入结果 -->
|
||
<div v-if="importResult" class="import-result">
|
||
<a-alert
|
||
:type="importResult.failed.length > 0 ? 'warning' : 'success'"
|
||
:title="getResultTitle()"
|
||
:description="getResultDescription()"
|
||
show-icon
|
||
/>
|
||
|
||
<div v-if="importResult.failed.length > 0" class="result-details">
|
||
<h4>失败文件列表:</h4>
|
||
<div class="failed-list">
|
||
<div
|
||
v-for="failed in importResult.failed"
|
||
:key="failed.filename"
|
||
class="failed-item"
|
||
>
|
||
<span class="filename">{{ failed.filename }}</span>
|
||
<span class="error">{{ failed.error }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import {
|
||
IconClose,
|
||
IconDelete,
|
||
IconUpload,
|
||
} from '@arco-design/web-vue/es/icon'
|
||
import {
|
||
getImageSources,
|
||
getProjectTree,
|
||
importImages,
|
||
} from '@/apis/industrial-image'
|
||
import type {
|
||
ImageImportParams,
|
||
IndustrialImage,
|
||
ProjectTreeNode,
|
||
} from '@/apis/industrial-image/type'
|
||
|
||
interface Props {
|
||
visible: boolean
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'update:visible', visible: boolean): void
|
||
(e: 'importSuccess', result: { success: IndustrialImage[], failed: any[] }): void
|
||
}
|
||
|
||
interface FileItem {
|
||
file: File
|
||
name: string
|
||
size: number
|
||
preview: string
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<Emits>()
|
||
|
||
// 响应式数据
|
||
const importing = ref(false)
|
||
const importProgress = ref(0)
|
||
const importStatus = ref<'normal' | 'success' | 'warning' | 'danger'>('normal')
|
||
const progressText = ref('')
|
||
const importResult = ref<{ success: IndustrialImage[], failed: any[] } | null>(null)
|
||
|
||
const form = ref({
|
||
imageSource: '',
|
||
projectId: '',
|
||
componentId: '',
|
||
uploadUser: '',
|
||
altitude: '',
|
||
latitude: '',
|
||
longitude: '',
|
||
settings: [] as string[],
|
||
annotationTypes: [] as string[],
|
||
})
|
||
|
||
const fileList = ref<FileItem[]>([])
|
||
const projectTree = ref<ProjectTreeNode[]>([])
|
||
const defectTypes = ref<Array<{ id: string, name: string, description?: string, color?: string }>>([])
|
||
const imageSources = ref<Array<{ id: string, name: string, code: string }>>([])
|
||
const loadingImageSources = ref(false)
|
||
|
||
// 计算属性
|
||
const visible = computed({
|
||
get: () => props.visible,
|
||
set: (value) => emit('update:visible', value),
|
||
})
|
||
|
||
const componentOptions = computed(() => {
|
||
const findComponents = (nodes: ProjectTreeNode[]): Array<{ label: string, value: string }> => {
|
||
const options: Array<{ label: string, value: string }> = []
|
||
|
||
nodes.forEach((node) => {
|
||
if (node.type === 'component' || node.type === 'blade' || node.type === 'tower') {
|
||
options.push({
|
||
label: node.name,
|
||
value: node.id,
|
||
})
|
||
}
|
||
if (node.children) {
|
||
options.push(...findComponents(node.children))
|
||
}
|
||
})
|
||
|
||
return options
|
||
}
|
||
|
||
return form.value.projectId ? findComponents(projectTree.value) : []
|
||
})
|
||
|
||
const defectTypeOptions = computed(() => {
|
||
return defectTypes.value.map((type) => ({
|
||
label: type.name,
|
||
value: type.id,
|
||
}))
|
||
})
|
||
|
||
const imageSourceOptions = computed(() => {
|
||
return imageSources.value.map((source) => ({
|
||
label: source.name,
|
||
value: source.code,
|
||
}))
|
||
})
|
||
|
||
// 方法
|
||
const loadProjectTree = async () => {
|
||
try {
|
||
const res = await getProjectTree()
|
||
projectTree.value = res.data
|
||
} catch (error) {
|
||
console.error('加载项目树失败:', error)
|
||
}
|
||
}
|
||
|
||
// const loadDefectTypes = async () => {
|
||
// try {
|
||
// const res = await getDefectTypes()
|
||
// defectTypes.value = res.data
|
||
// } catch (error) {
|
||
// console.error('加载缺陷类型失败:', error)
|
||
// }
|
||
// }
|
||
|
||
const loadImageSources = async () => {
|
||
loadingImageSources.value = true
|
||
try {
|
||
const res = await getImageSources()
|
||
if (res.data && res.data.length > 0 && res.data[0].data) {
|
||
imageSources.value = res.data[0].data
|
||
if (imageSources.value.length > 0) {
|
||
form.value.imageSource = imageSources.value[0].code
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载图像来源失败:', error)
|
||
} finally {
|
||
loadingImageSources.value = false
|
||
}
|
||
}
|
||
|
||
const onProjectChange = (value: string) => {
|
||
form.value.componentId = ''
|
||
}
|
||
|
||
const handleFileChange = (fileList: any) => {
|
||
const files = Array.from(fileList.target?.files || []) as File[]
|
||
|
||
files.forEach((file) => {
|
||
if (!file.type.startsWith('image/')) {
|
||
Message.warning(`文件 ${file.name} 不是图像文件`)
|
||
return
|
||
}
|
||
|
||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||
Message.warning(`文件 ${file.name} 大小超过10MB`)
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
const fileItem: FileItem = {
|
||
file,
|
||
name: file.name,
|
||
size: file.size,
|
||
preview: e.target?.result as string,
|
||
}
|
||
fileList.value.push(fileItem)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
const removeFile = (index: number) => {
|
||
fileList.value.splice(index, 1)
|
||
}
|
||
|
||
const clearFiles = () => {
|
||
fileList.value = []
|
||
}
|
||
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes === 0) return '0 B'
|
||
const k = 1024
|
||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
||
}
|
||
|
||
const handleImport = async () => {
|
||
if (!form.value.imageSource) {
|
||
Message.warning('请选择图像来源')
|
||
return
|
||
}
|
||
|
||
if (!form.value.projectId) {
|
||
Message.warning('请选择目标项目')
|
||
return
|
||
}
|
||
|
||
if (fileList.value.length === 0) {
|
||
Message.warning('请选择要导入的图像文件')
|
||
return
|
||
}
|
||
|
||
importing.value = true
|
||
importProgress.value = 0
|
||
importStatus.value = 'normal'
|
||
progressText.value = '正在导入图像...'
|
||
importResult.value = null
|
||
|
||
try {
|
||
const files = fileList.value.map((item) => item.file)
|
||
const params: ImageImportParams = {
|
||
imageSource: form.value.imageSource,
|
||
projectId: form.value.projectId,
|
||
componentId: form.value.componentId || undefined,
|
||
uploadUser: form.value.uploadUser || undefined,
|
||
altitude: form.value.altitude || undefined,
|
||
latitude: form.value.latitude || undefined,
|
||
longitude: form.value.longitude || undefined,
|
||
autoAnnotate: form.value.settings.includes('autoAnnotate'),
|
||
annotationTypes: form.value.settings.includes('autoAnnotate') ? form.value.annotationTypes : undefined,
|
||
}
|
||
|
||
// 模拟进度更新
|
||
const progressInterval = setInterval(() => {
|
||
if (importProgress.value < 90) {
|
||
importProgress.value += 10
|
||
}
|
||
}, 200)
|
||
|
||
const res = await importImages(files, params)
|
||
|
||
clearInterval(progressInterval)
|
||
importProgress.value = 100
|
||
importStatus.value = 'success'
|
||
progressText.value = '导入完成!'
|
||
|
||
// 注意:真实API返回的数据结构可能不同,需要根据实际情况调整
|
||
const mockResult = {
|
||
success: files.map((file, index) => ({
|
||
id: `img_${Date.now()}_${index}`,
|
||
name: file.name,
|
||
path: `uploaded/${file.name}`,
|
||
size: file.size,
|
||
type: file.type,
|
||
projectId: form.value.projectId,
|
||
componentId: form.value.componentId,
|
||
createTime: new Date().toISOString(),
|
||
})),
|
||
failed: [],
|
||
}
|
||
|
||
importResult.value = mockResult
|
||
emit('importSuccess', mockResult)
|
||
|
||
Message.success(`成功导入 ${files.length} 个图像文件`)
|
||
} catch (error) {
|
||
console.error('导入失败:', error)
|
||
importProgress.value = 100
|
||
importStatus.value = 'danger'
|
||
progressText.value = '导入失败!'
|
||
Message.error('导入图像失败')
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
if (importing.value) {
|
||
Message.warning('正在导入中,请稍后再试')
|
||
return
|
||
}
|
||
|
||
resetForm()
|
||
visible.value = false
|
||
}
|
||
|
||
const resetForm = () => {
|
||
form.value = {
|
||
imageSource: '',
|
||
projectId: '',
|
||
componentId: '',
|
||
uploadUser: '',
|
||
altitude: '',
|
||
latitude: '',
|
||
longitude: '',
|
||
settings: [],
|
||
annotationTypes: [],
|
||
}
|
||
fileList.value = []
|
||
importResult.value = null
|
||
importProgress.value = 0
|
||
importStatus.value = 'normal'
|
||
progressText.value = ''
|
||
}
|
||
|
||
const getResultTitle = () => {
|
||
if (!importResult.value) return ''
|
||
|
||
const { success, failed } = importResult.value
|
||
if (failed.length === 0) {
|
||
return `导入成功!共导入 ${success.length} 个图像文件`
|
||
} else {
|
||
return `导入完成!成功 ${success.length} 个,失败 ${failed.length} 个`
|
||
}
|
||
}
|
||
|
||
const getResultDescription = () => {
|
||
if (!importResult.value) return ''
|
||
|
||
const { success, failed } = importResult.value
|
||
if (failed.length === 0) {
|
||
return '所有图像文件都已成功导入到指定项目中'
|
||
} else {
|
||
return `部分文件导入失败,请检查失败原因并重新导入`
|
||
}
|
||
}
|
||
|
||
// 监听器
|
||
watch(visible, (newValue) => {
|
||
if (newValue) {
|
||
loadProjectTree()
|
||
// loadDefectTypes()
|
||
loadImageSources()
|
||
} else {
|
||
resetForm()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.image-import {
|
||
.import-content {
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.upload-section {
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.upload-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 120px;
|
||
border: 2px dashed #d9d9d9;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
transition: all 0.3s;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
border-color: #1890ff;
|
||
background: #f0f8ff;
|
||
}
|
||
}
|
||
|
||
.upload-drag-icon {
|
||
margin-bottom: 16px;
|
||
color: #999;
|
||
}
|
||
|
||
.upload-text {
|
||
text-align: center;
|
||
|
||
p {
|
||
margin: 0;
|
||
|
||
&.upload-hint {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
|
||
.file-list {
|
||
margin-top: 20px;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #fafafa;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
|
||
h4 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.list-content {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.file-info {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.file-preview {
|
||
width: 40px;
|
||
height: 40px;
|
||
margin-right: 12px;
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
border: 1px solid #e8e8e8;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.file-details {
|
||
flex: 1;
|
||
}
|
||
|
||
.file-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.file-size {
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
|
||
.import-progress {
|
||
margin-top: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.progress-text {
|
||
margin-top: 8px;
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
|
||
.import-result {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.result-details {
|
||
margin-top: 16px;
|
||
|
||
h4 {
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.failed-list {
|
||
max-height: 150px;
|
||
overflow-y: auto;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.failed-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.filename {
|
||
font-size: 12px;
|
||
color: #333;
|
||
}
|
||
|
||
.error {
|
||
font-size: 12px;
|
||
color: #ff4d4f;
|
||
}
|
||
}
|
||
</style>
|