539 lines
14 KiB
Vue
539 lines
14 KiB
Vue
<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>
|