634 lines
16 KiB
Vue
634 lines
16 KiB
Vue
<template>
|
||
<GiPageLayout>
|
||
<div class="data-preprocessing-container">
|
||
<!-- 上传进度框 -->
|
||
<div v-if="uploadState.visible" class="upload-progress-fixed">
|
||
<a-card :title="`上传进度 (${uploadState.percent}%)`" :bordered="false">
|
||
<a-progress
|
||
:percent="uploadState.percent"
|
||
:status="uploadState.status"
|
||
:stroke-width="16"
|
||
/>
|
||
<div class="progress-details">
|
||
<p><icon-file /> {{ uploadState.currentFile || '准备中...' }}</p>
|
||
<p><icon-check-circle /> 已完成 {{ uploadState.uploadedCount }}/{{ uploadState.totalCount }}</p>
|
||
<p><icon-clock-circle /> 状态: {{ getStatusText(uploadState.status) }}</p>
|
||
</div>
|
||
</a-card>
|
||
</div>
|
||
|
||
<div class="step-panel">
|
||
<div class="step-header">
|
||
<h3>批量上传图片</h3>
|
||
</div>
|
||
|
||
<div class="data-selection">
|
||
<a-form :model="form" layout="vertical">
|
||
<!-- 项目选择 -->
|
||
<a-form-item label="所属项目" required>
|
||
<a-select
|
||
v-model="form.projectId"
|
||
placeholder="请选择项目"
|
||
allow-search
|
||
:filter-option="filterProjectOption"
|
||
>
|
||
<a-option
|
||
v-for="project in projectList"
|
||
:key="project.id"
|
||
:value="project.id"
|
||
:label="project.name"
|
||
/>
|
||
</a-select>
|
||
</a-form-item>
|
||
|
||
<!-- 图片来源选择 -->
|
||
<a-form-item label="图片来源" required>
|
||
<a-select
|
||
v-model="form.imageSource"
|
||
placeholder="请选择图片来源"
|
||
allow-search
|
||
:filter-option="filterSourceOption"
|
||
>
|
||
<a-option
|
||
v-for="source in imageSources"
|
||
:key="source.value"
|
||
:value="source.value"
|
||
:label="source.label"
|
||
/>
|
||
</a-select>
|
||
</a-form-item>
|
||
|
||
<!-- 文件夹操作 -->
|
||
<a-form-item label="文件操作">
|
||
<div class="folder-actions">
|
||
<a-upload
|
||
ref="uploadRef"
|
||
directory
|
||
:multiple="true"
|
||
:show-file-list="false"
|
||
accept="image/*"
|
||
:key="uploadKey"
|
||
@change="handleFolderSelect"
|
||
>
|
||
<template #upload-button>
|
||
<a-button type="outline">
|
||
<template #icon>
|
||
<icon-folder />
|
||
</template>
|
||
选择文件夹
|
||
</a-button>
|
||
</template>
|
||
</a-upload>
|
||
<a-button
|
||
type="outline"
|
||
status="warning"
|
||
@click="clearFileList"
|
||
:disabled="selectedFiles.length === 0"
|
||
>
|
||
<template #icon>
|
||
<icon-delete />
|
||
</template>
|
||
清空列表
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
:loading="uploading"
|
||
:disabled="!canUpload"
|
||
@click="handleUpload"
|
||
>
|
||
<template #icon>
|
||
<icon-upload />
|
||
</template>
|
||
开始上传
|
||
</a-button>
|
||
</div>
|
||
</a-form-item>
|
||
|
||
<!-- 文件列表 -->
|
||
<a-form-item v-if="selectedFiles.length > 0">
|
||
<div class="file-list-container">
|
||
<div class="file-list-header">
|
||
<span>已选择 {{ selectedFiles.length }} 个文件(选中 {{ checkedFiles.length }} 个)</span>
|
||
<a-checkbox
|
||
v-model="selectAll"
|
||
:indeterminate="indeterminate"
|
||
@change="handleSelectAllChange"
|
||
>
|
||
全选
|
||
</a-checkbox>
|
||
</div>
|
||
|
||
<div class="file-table-wrapper">
|
||
<a-table
|
||
:data="selectedFiles"
|
||
:columns="fileColumns"
|
||
:row-selection="rowSelection"
|
||
:pagination="false"
|
||
row-key="uid"
|
||
size="small"
|
||
bordered
|
||
>
|
||
<!-- 表格列模板 -->
|
||
<template #thumbnail="{ record }">
|
||
<div class="thumbnail-cell">
|
||
<img
|
||
v-if="isImage(record.type)"
|
||
:src="record.preview"
|
||
class="thumbnail-image"
|
||
alt="预览"
|
||
/>
|
||
<div v-else class="file-icon">
|
||
<icon-file />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template #fileType="{ record }">
|
||
<a-tag :color="getFileTypeColor(record.type)" size="small">
|
||
{{ record.type }}
|
||
</a-tag>
|
||
</template>
|
||
<template #fileSize="{ record }">
|
||
{{ formatFileSize(record.size) }}
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</GiPageLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, nextTick } from 'vue'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import type { UploadItem } from '@arco-design/web-vue/es/upload'
|
||
import type { SelectOptionData, TableColumnData, TableRowSelection } from '@arco-design/web-vue'
|
||
import axios from 'axios'
|
||
|
||
import {
|
||
getProjectList,
|
||
getImageSources
|
||
} from '@/apis/industrial-image'
|
||
|
||
// 类型定义
|
||
interface FileItem {
|
||
uid: string
|
||
name: string
|
||
type: string
|
||
size: number
|
||
file: File
|
||
preview?: string
|
||
}
|
||
|
||
type UploadStatus = 'waiting' | 'uploading' | 'success' | 'error'
|
||
|
||
// 数据状态
|
||
const projectList = ref([])
|
||
|
||
const imageSources = ref([])
|
||
|
||
// 获取项目列表
|
||
const fetchProjectList = async () => {
|
||
try {
|
||
const res = await getProjectList({ page: 1, pageSize: 1000 });
|
||
projectList.value = res.data.map(item => ({
|
||
name: item.projectName,
|
||
id: item.projectId
|
||
}))
|
||
} catch (error) {
|
||
Message.error('获取项目列表失败')
|
||
} finally {
|
||
}
|
||
}
|
||
|
||
// 获取图片来源
|
||
const fetchImageSourceList = async () => {
|
||
try {
|
||
const res = await getImageSources();
|
||
res.data.forEach(item => {
|
||
const key = Object.keys(item)[0];
|
||
const value = item[key];
|
||
imageSources.value.push({
|
||
label: value,
|
||
value:key
|
||
});
|
||
});
|
||
} catch (error) {
|
||
Message.error('获取项目列表失败')
|
||
} finally {
|
||
}
|
||
}
|
||
|
||
const form = reactive({
|
||
projectId: undefined as number | undefined,
|
||
imageSource: undefined as string | undefined
|
||
})
|
||
|
||
const selectedFiles = ref<FileItem[]>([])
|
||
const checkedFiles = ref<string[]>([])
|
||
const uploading = ref(false)
|
||
const uploadRef = ref()
|
||
const uploadKey = ref(0)
|
||
|
||
const uploadState = reactive({
|
||
visible: false,
|
||
percent: 0,
|
||
status: 'waiting' as UploadStatus,
|
||
currentFile: '',
|
||
uploadedCount: 0,
|
||
totalCount: 0
|
||
})
|
||
|
||
// 计算属性
|
||
const canUpload = computed(() => {
|
||
return checkedFiles.value.length > 0
|
||
&& !!form.projectId
|
||
&& !!form.imageSource
|
||
&& !uploading.value
|
||
})
|
||
|
||
const selectAll = ref(false)
|
||
const indeterminate = computed(() => {
|
||
return checkedFiles.value.length > 0 &&
|
||
checkedFiles.value.length < selectedFiles.value.length
|
||
})
|
||
|
||
// 表格列配置
|
||
const fileColumns: TableColumnData[] = [
|
||
{
|
||
title: '选择',
|
||
dataIndex: 'selection',
|
||
type: 'selection',
|
||
width: 60,
|
||
align: 'center'
|
||
},
|
||
{
|
||
title: '预览',
|
||
dataIndex: 'thumbnail',
|
||
slotName: 'thumbnail',
|
||
width: 100,
|
||
align: 'center'
|
||
},
|
||
{
|
||
title: '文件名',
|
||
dataIndex: 'name',
|
||
ellipsis: true,
|
||
tooltip: true,
|
||
width: 300
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'type',
|
||
slotName: 'fileType',
|
||
width: 100,
|
||
align: 'center'
|
||
},
|
||
{
|
||
title: '大小',
|
||
dataIndex: 'size',
|
||
slotName: 'fileSize',
|
||
width: 100,
|
||
align: 'center'
|
||
}
|
||
]
|
||
|
||
// 行选择配置
|
||
const rowSelection = reactive<TableRowSelection>({
|
||
type: 'checkbox',
|
||
showCheckedAll: false,
|
||
selectedRowKeys: checkedFiles,
|
||
onChange: (rowKeys: string[]) => {
|
||
checkedFiles.value = rowKeys
|
||
selectAll.value = rowKeys.length === selectedFiles.value.length
|
||
}
|
||
})
|
||
|
||
// 搜索过滤函数
|
||
const filterProjectOption = (inputValue: string, option: SelectOptionData) => {
|
||
return option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||
}
|
||
|
||
const filterSourceOption = (inputValue: string, option: SelectOptionData) => {
|
||
return option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||
}
|
||
|
||
// 文件选择处理
|
||
const handleFolderSelect = async (fileList: UploadItem[]) => {
|
||
// 1. 清空现有状态
|
||
clearFileList()
|
||
|
||
// 2. 处理新文件
|
||
const newFiles: FileItem[] = []
|
||
for (const item of fileList) {
|
||
const file = item.file
|
||
if (!file) continue
|
||
|
||
const fileType = file.type.split('/')[0] || 'unknown'
|
||
const preview = fileType === 'image' ? URL.createObjectURL(file) : undefined
|
||
|
||
newFiles.push({
|
||
uid: item.uid,
|
||
name: file.name,
|
||
type: fileType,
|
||
size: file.size,
|
||
file: file,
|
||
preview: preview
|
||
})
|
||
}
|
||
|
||
// 3. 更新状态
|
||
selectedFiles.value = newFiles
|
||
checkedFiles.value = newFiles.map(f => f.uid)
|
||
selectAll.value = true
|
||
|
||
// 4. 重置上传组件(通过改变key强制重新创建组件)
|
||
uploadKey.value++
|
||
}
|
||
|
||
// 清空文件列表
|
||
const clearFileList = () => {
|
||
// 释放预览URL
|
||
selectedFiles.value.forEach(file => {
|
||
if (file.preview) URL.revokeObjectURL(file.preview)
|
||
})
|
||
|
||
// 重置状态
|
||
selectedFiles.value = []
|
||
checkedFiles.value = []
|
||
selectAll.value = false
|
||
}
|
||
|
||
// 处理全选变化
|
||
const handleSelectAllChange = (checked: boolean) => {
|
||
checkedFiles.value = checked ? selectedFiles.value.map(f => f.uid) : []
|
||
}
|
||
|
||
// 上传处理
|
||
const handleUpload = async () => {
|
||
if (!canUpload.value) {
|
||
Message.error('请完成所有必填项并选择文件')
|
||
return
|
||
}
|
||
|
||
// 重置上传状态
|
||
Object.assign(uploadState, {
|
||
visible: true,
|
||
percent: 0,
|
||
status: 'uploading',
|
||
currentFile: '',
|
||
uploadedCount: 0,
|
||
totalCount: checkedFiles.value.length
|
||
})
|
||
|
||
uploading.value = true
|
||
|
||
try {
|
||
const filesToUpload = selectedFiles.value.filter(f => checkedFiles.value.includes(f.uid))
|
||
|
||
const formData = new FormData()
|
||
|
||
// 添加所有图片文件到FormData
|
||
filesToUpload.forEach(file => {
|
||
formData.append('files', file);
|
||
});
|
||
|
||
let url =`http://pms.dtyx.net:9158/image/${form.projectId}/${form.imageSource}/upload-batch`;
|
||
/*let res = await axios.post(
|
||
url,
|
||
formData,
|
||
{
|
||
onUploadProgress: (progressEvent) => {
|
||
if (progressEvent.total) {
|
||
uploadedBytes = progressEvent.loaded
|
||
const elapsedTime = (Date.now() - startTime) / 1000
|
||
const speed = uploadedBytes / elapsedTime
|
||
uploadState.speed = `${formatFileSize(speed)}/s`
|
||
|
||
const remainingBytes = totalBytes - uploadedBytes
|
||
uploadState.remainingTime = formatTime(remainingBytes / speed)
|
||
|
||
uploadState.percent = Math.round((uploadedBytes / totalBytes) * 100)
|
||
}
|
||
},
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data'
|
||
}
|
||
}
|
||
)*/
|
||
// 使用XMLHttpRequest上传
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
// 上传进度事件
|
||
xhr.upload.addEventListener('progress', (event) => {
|
||
if (event.lengthComputable) {
|
||
}
|
||
});
|
||
|
||
xhr.onreadystatechange = () => {
|
||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
const response = JSON.parse(xhr.responseText);
|
||
|
||
} catch (e) {
|
||
|
||
}
|
||
} else {
|
||
|
||
}
|
||
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => {
|
||
|
||
};
|
||
|
||
xhr.open('POST', url, true);
|
||
xhr.send(formData);
|
||
|
||
uploadState.status = 'success'
|
||
Message.success(`成功上传 ${filesToUpload.length} 个文件`)
|
||
} catch (error) {
|
||
uploadState.status = 'error'
|
||
Message.error('上传失败: ' + (error as Error).message)
|
||
} finally {
|
||
uploading.value = false
|
||
// 5秒后自动隐藏进度框
|
||
setTimeout(() => uploadState.visible = false, 5000)
|
||
}
|
||
}
|
||
|
||
// 辅助函数
|
||
const getStatusText = (status: UploadStatus) => {
|
||
const statusMap = {
|
||
waiting: '等待上传',
|
||
uploading: '上传中',
|
||
success: '上传成功',
|
||
error: '上传失败'
|
||
}
|
||
return statusMap[status] || status
|
||
}
|
||
|
||
const getFileTypeColor = (type: string) => {
|
||
const colors: Record<string, string> = {
|
||
image: 'arcoblue',
|
||
video: 'green',
|
||
audio: 'orange',
|
||
document: 'purple'
|
||
}
|
||
return colors[type.toLowerCase()] || 'gray'
|
||
}
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
}
|
||
|
||
const isImage = (type: string) => type === 'image'
|
||
|
||
// 在组件挂载时获取项目列表
|
||
onMounted(() => {
|
||
fetchProjectList()
|
||
fetchImageSourceList()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.data-preprocessing-container {
|
||
position: relative;
|
||
padding: 20px;
|
||
min-height: calc(100vh - 40px);
|
||
}
|
||
|
||
.step-panel {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.step-header {
|
||
margin-bottom: 24px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
padding-bottom: 16px;
|
||
|
||
h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--color-text-1);
|
||
}
|
||
}
|
||
|
||
.folder-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
|
||
> * {
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.file-list-container {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
padding: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.file-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.file-table-wrapper {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.file-table {
|
||
width: 100%;
|
||
|
||
:deep(.arco-table-th) {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
background-color: var(--color-fill-2);
|
||
}
|
||
}
|
||
|
||
.thumbnail-cell {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 60px;
|
||
|
||
.thumbnail-image {
|
||
max-height: 60px;
|
||
max-width: 80px;
|
||
object-fit: contain;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.file-icon {
|
||
font-size: 24px;
|
||
color: var(--color-text-3);
|
||
}
|
||
}
|
||
|
||
/* 上传进度框样式 */
|
||
.upload-progress-fixed {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
width: 360px;
|
||
z-index: 1000;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||
border-radius: 8px;
|
||
animation: fadeIn 0.3s ease;
|
||
|
||
:deep(.arco-card-header) {
|
||
border-bottom: 1px solid var(--color-border);
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
:deep(.arco-progress-text) {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.progress-details {
|
||
margin-top: 12px;
|
||
font-size: 13px;
|
||
color: var(--color-text-2);
|
||
|
||
p {
|
||
display: flex;
|
||
align-items: center;
|
||
margin: 6px 0;
|
||
|
||
.arco-icon {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
</style> |