Files
BMS/bms-front/src/components/ExcelImportDialog/index.vue
T
2026-09-28 18:01:26 +08:00

539 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose" class="excel-import-dialog">
<div class="import-body">
<!-- 业务说明(可选) -->
<div v-if="importDesc.length" class="import-desc">
<el-icon class="desc-icon"><Info-Filled /></el-icon>
<ul class="desc-list">
<li v-for="(line, i) in importDesc" :key="i">{{ line }}</li>
</ul>
</div>
<div v-if="$slots.extra" class="import-extra">
<slot name="extra" />
</div>
<!-- 上传拖拽区 -->
<el-upload
ref="uploadRef"
:limit="1"
:accept="accept"
:headers="headers"
:action="uploadUrl"
:disabled="isUploading"
:on-progress="handleProgress"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-success="handleSuccess"
:auto-upload="false"
drag
class="upload-area"
>
<div class="upload-placeholder">
<el-icon class="upload-icon"><upload-filled /></el-icon>
<div class="upload-title">点击或拖拽上传文件</div>
<div class="upload-hint">支持 {{ accept }} 格式,单次最多导入 1 个文件,文件大小不超过 {{ fileSizeMb }} MB</div>
</div>
</el-upload>
<!-- 已选文件 + 进度 -->
<div v-if="selectedFile || isUploading" class="file-progress">
<div v-if="selectedFile" class="selected-file">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name" :title="selectedFile.name">{{ selectedFile.name }}</span>
<el-icon v-if="!isUploading" class="file-remove" @click="handleClearFile"><CircleClose /></el-icon>
</div>
<el-progress v-if="isUploading" :percentage="progressPercent" :stroke-width="6" :show-text="true" status="success" class="upload-progress" />
</div>
<!-- 校验错误 -->
<div v-if="validateError" class="validate-error">
<el-icon><Warning-Filled /></el-icon>
<span>{{ validateError }}</span>
</div>
<!-- 选项与提示 -->
<div class="import-options">
<el-checkbox v-if="showUpdateSupport" v-model="updateSupport" class="update-checkbox">
<span class="update-label">{{ updateSupportLabel }}</span>
</el-checkbox>
<div v-if="showUpdateSupport && updateSupportHint" class="update-hint">{{ updateSupportHint }}</div>
<div class="import-tips">
<el-icon class="tip-icon"><Info-Filled /></el-icon>
<div class="tip-text">
仅允许上传 {{ accept }} 格式文件,文件大小不超过 {{ fileSizeMb }} MB。
<el-link v-if="hasTemplate" type="primary" underline="never" class="template-link" @click="handleDownloadTemplate">下载模板</el-link>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" :disabled="!selectedFile || isUploading" :loading="isUploading" @click="handleSubmit">{{ submitText }}</el-button>
<el-button @click="visible = false" :disabled="isUploading">取 消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { getToken } from '@/utils/auth'
const { proxy } = getCurrentInstance()
const props = defineProps({
// 对话框标题
title: {
type: String,
default: '数据导入'
},
// 对话框宽度
width: {
type: String,
default: '420px'
},
// 上传接口地址(必传)
action: {
type: String,
default: ''
},
// 模板下载接口地址(后端接口,走 proxy.download),不传则不显示下载模板链接
templateAction: {
type: String,
default: ''
},
// 静态模板文件地址(public 目录下相对路径,如 /templates/product/xxx.xlsx),
// 与 templateAction 二选一,静态地址优先
templateUrl: {
type: String,
default: ''
},
// 模板下载文件名(不含扩展名;静态下载时为保存文件名,接口下载时为文件名前缀)
templateFileName: {
type: String,
default: 'template'
},
// 覆盖更新勾选框的说明文字
updateSupportLabel: {
type: String,
default: '是否更新已经存在的数据'
},
// 勾选框下方补充说明(长文案放这里,避免与复选框挤叠)
updateSupportHint: {
type: String,
default: ''
},
// 打开时是否默认勾选“更新已存在”
defaultUpdateSupport: {
type: Boolean,
default: false
},
// 是否显示「覆盖更新」勾选(设为 false 可完全隐藏该选项)
showUpdateSupport: {
type: Boolean,
default: true
},
// 接收的文件类型(扩展名或 MIME),默认 xlsx/xls
accept: {
type: String,
default: '.xlsx, .xls'
},
// 单文件大小上限(MB),默认 50
fileSizeMb: {
type: Number,
default: 50
},
// 业务说明(字符串数组),显示在弹窗顶部 importDesc 段
importDesc: {
type: Array,
default: () => []
},
extraQuery: {
type: Object,
default: () => ({})
},
beforeSubmit: {
type: Function,
default: null
},
// Custom async submit: (uploadFile) => Promise<string|void>
submitHandler: {
type: Function,
default: null
},
// 确定按钮文字
submitText: {
type: String,
default: '确 定'
}
})
const emit = defineEmits(['success'])
const uploadRef = ref(null)
const visible = ref(false)
const selectedFile = ref(null)
const isUploading = ref(false)
const updateSupport = ref(false)
const progressPercent = ref(0)
const validateError = ref('')
const headers = { Authorization: 'Bearer ' + getToken() }
const uploadUrl = computed(() => {
const query = new URLSearchParams()
query.set('updateSupport', updateSupport.value ? '1' : '0')
const extra = props.extraQuery || {}
Object.keys(extra).forEach(key => {
const value = extra[key]
if (value !== undefined && value !== null && value !== '') {
query.set(key, value)
}
})
return import.meta.env.VITE_APP_BASE_API + props.action + '?' + query.toString()
})
const hasTemplate = computed(() => !!(props.templateUrl || props.templateAction))
// 打开对话框(供父组件通过 ref 调用)
function open() {
updateSupport.value = props.defaultUpdateSupport
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
visible.value = true
nextTick(() => {
selectedFile.value = null
uploadRef.value?.clearFiles()
})
}
// 关闭时清理
function handleClose() {
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
}
// 下载模板:静态文件直接 <a download>,接口走 proxy.download
function handleDownloadTemplate() {
if (props.templateUrl) {
const link = document.createElement('a')
link.href = props.templateUrl
link.download = props.templateFileName + '.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
proxy.download(props.templateAction, {}, `${props.templateFileName}_${new Date().getTime()}.xlsx`)
}
// 上传进度
function handleProgress(event, file) {
isUploading.value = true
if (file && typeof file.percent === 'number') {
progressPercent.value = Math.round(file.percent)
}
}
// 文件类型/大小校验
function checkFile(file) {
if (!file) return '请先选择文件'
const name = (file.name || '').toLowerCase()
const types = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (types.length) {
const ok = types.some(t => {
const ext = t.startsWith('.') ? t : '.' + t
return name.endsWith(ext)
})
if (!ok) return `仅允许上传 ${props.accept} 格式文件`
}
const sizeMb = file.size / 1024 / 1024
if (sizeMb > props.fileSizeMb) return `文件大小不能超过 ${props.fileSizeMb} MB`
return ''
}
/** 文件选择处理 */
const handleFileChange = (file, fileList) => {
progressPercent.value = 0
const err = checkFile(file)
if (err) {
validateError.value = err
selectedFile.value = null
nextTick(() => uploadRef.value?.clearFiles())
return
}
validateError.value = ''
selectedFile.value = file
}
/** 文件删除处理 */
const handleFileRemove = (file, fileList) => {
selectedFile.value = null
progressPercent.value = 0
validateError.value = ''
}
/** 手动清除已选文件 */
function handleClearFile() {
selectedFile.value = null
validateError.value = ''
uploadRef.value?.clearFiles()
}
// 上传成功
function handleSuccess(response) {
visible.value = false
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
emit('success')
}
// 提交上传
async function handleSubmit() {
if (typeof props.beforeSubmit === 'function') {
const msg = props.beforeSubmit()
if (msg) {
proxy.$modal.msgError(msg)
return
}
}
const file = selectedFile.value
const err = checkFile(file)
if (err) {
proxy.$modal.msgError(err)
return
}
if (typeof props.submitHandler === 'function') {
isUploading.value = true
progressPercent.value = 0
try {
const resultMsg = await props.submitHandler(file)
visible.value = false
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
const html = resultMsg == null || resultMsg === ''
? '导入完成'
: String(resultMsg)
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + html + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
emit('success')
} catch (e) {
const tip = (e && e.message) ? e.message : '导入失败'
proxy.$modal.msgError(tip)
} finally {
isUploading.value = false
progressPercent.value = 0
}
return
}
uploadRef.value.submit()
}
defineExpose({ open })
</script>
<style scoped>
.excel-import-dialog :deep(.el-dialog__body) {
padding-bottom: 8px;
}
.import-body {
padding: 0 10px;
}
.upload-area :deep(.el-upload-dragger) {
padding: 44px 20px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
transition: border-color 0.2s, background-color 0.2s;
}
.upload-area :deep(.el-upload-dragger:hover),
.upload-area :deep(.el-upload-dragger.is-dragover) {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
.upload-placeholder {
text-align: center;
color: var(--el-text-color-regular);
}
.upload-icon {
font-size: 46px;
color: var(--el-color-primary);
}
.upload-title {
font-size: 16px;
margin-top: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
}
.upload-hint {
font-size: 13px;
margin-top: 8px;
color: var(--el-text-color-secondary);
}
.selected-file {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 6px;
color: var(--el-text-color-primary);
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
flex-shrink: 0;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.file-remove {
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
flex-shrink: 0;
}
.file-remove:hover {
color: var(--el-color-danger);
}
.import-options {
margin-top: 16px;
padding: 14px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.update-checkbox {
display: flex !important;
align-items: flex-start;
height: auto !important;
white-space: normal;
font-weight: 500;
color: var(--el-text-color-primary);
width: 100%;
}
.update-checkbox :deep(.el-checkbox__input) {
margin-top: 3px;
flex-shrink: 0;
}
.update-checkbox :deep(.el-checkbox__label) {
white-space: normal;
line-height: 1.5;
word-break: break-word;
padding-left: 8px;
}
.update-label {
display: inline-block;
line-height: 1.5;
}
.update-hint {
margin: 6px 0 0 22px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
word-break: break-word;
}
.import-tips {
margin-top: 10px;
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.tip-icon {
margin-top: 1px;
font-size: 14px;
color: var(--el-color-info);
flex-shrink: 0;
}
.template-link {
font-size: 12px;
vertical-align: baseline;
}
.import-desc {
margin-bottom: 14px;
padding: 12px 14px;
background: var(--el-color-info-light-9);
border: 1px solid var(--el-color-info-light-7);
border-radius: 8px;
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.desc-icon {
margin-top: 2px;
color: var(--el-color-info);
flex-shrink: 0;
}
.desc-list {
margin: 0;
padding-left: 18px;
flex: 1;
}
.import-extra {
margin-bottom: 14px;
}
.file-progress {
margin-top: 12px;
}
.upload-progress {
margin-top: 8px;
}
.validate-error {
margin-top: 12px;
padding: 8px 12px;
background: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-7);
border-radius: 6px;
color: var(--el-color-danger);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
</style>