This commit is contained in:
那些花儿
2025-07-02 19:22:58 +08:00
parent 725dc7b289
commit 56cbae8ec2
14 changed files with 1355 additions and 143 deletions
@@ -0,0 +1,994 @@
<script>
module.exports = {
name: "excelImport",
props: {
url: {
type: String,
required: true,
description: "后端接收文件的URL"
},
extra_params: {
type: Object,
default: () => ({}),
description: "上传时附加的参数"
},
template_url: {
type: String,
default: "",
description: "下载导入模板的URL地址"
},
mode: {
type: String,
default: "dialog",
validator: (value) => ["dialog", "direct"].includes(value),
description: "显示模式:dialog-弹窗模式(默认)direct-直接显示"
},
title: {
type: String,
default: "Excel 数据导入",
description: "弹窗标题(dialog模式下有效)"
},
width: {
type: String,
default: "800px",
description: "弹窗宽度(dialog模式下有效)"
},
visible: {
type: Boolean,
default: false,
description: "是否显示弹窗(dialog模式下有效)"
}
},
data() {
return {
selectedFile: null,
importing: false,
importComplete: false,
progress: 0,
progressText: "",
stats: {
totalRecords: 0,
successCount: 0,
failedCount: 0
},
errorDetails: [],
excelData: [],
dialogVisible: this.visible,
downloading: false
}
},
computed: {
progressColor() {
if (this.progress < 30) return "#F56C6C"
if (this.progress < 70) return "#E6A23C"
return "#67C23A"
},
successRate() {
if (this.stats.totalRecords === 0) return 0
return Math.round((this.stats.successCount / this.stats.totalRecords) * 100)
},
showTemplateBtn() {
return !!this.template_url
}
},
watch: {
visible(val) {
this.dialogVisible = val
},
dialogVisible(val) {
if (val !== this.visible) {
this.$emit("update:visible", val)
}
if (!val) {
this.resetUpload()
}
}
},
methods: {
handleFileChange(file) {
this.selectedFile = file.raw
this.resetStats()
},
beforeUpload(file) {
const isExcel =
file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.type === "application/vnd.ms-excel" ||
file.name.toLowerCase().endsWith(".xlsx") ||
file.name.toLowerCase().endsWith(".xls")
if (!isExcel) {
this.$message.error("只能上传 Excel 文件!")
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
this.$message.error("文件大小不能超过 10MB")
return false
}
return false // 阻止自动上传
},
async startImport() {
if (!this.selectedFile) {
this.$message.warning("请先选择文件!")
return
}
if (!this.url) {
this.$message.error("未配置上传URL")
return
}
this.importing = true
this.progress = 0
this.progressText = "准备上传文件..."
try {
// 创建FormData对象
const formData = new FormData()
formData.append("file", this.selectedFile)
// 添加额外参数
if (this.extra_params) {
Object.keys(this.extra_params).forEach((key) => {
formData.append(key, this.extra_params[key])
})
}
// 设置上传进度
await this.updateProgress(20, "正在上传文件...")
// 发送请求到后端
const { code, data } = await this.$axios.post(this.url, formData, {
headers: {
"Content-Type": "multipart/form-data"
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 40) / progressEvent.total)
this.updateProgress(20 + percentCompleted, "上传中...")
}
})
// 处理后端返回的结果
await this.updateProgress(70, "处理后端导入结果...")
if (code === 0 && data) {
// 处理成功
const result = data || {}
this.stats.totalRecords = result.totalRecords || 0
this.stats.successCount = result.successCount || 0
this.stats.failedCount = result.failedCount || 0
this.errorDetails = result.errorDetails || []
await this.updateProgress(100, "导入完成!")
setTimeout(() => {
this.importing = false
this.importComplete = true
this.$message.success("Excel 导入完成!")
this.$emit("import-success", result)
}, 500)
} else {
// 处理失败
throw new Error(msg || "导入失败")
}
} catch (error) {
this.importing = false
this.$message.error("导入失败:" + (error.message || "未知错误"))
this.$emit("import-error", error)
}
},
async downloadTemplate() {
if (!this.template_url) {
this.$message.warning("未配置模板下载地址!")
return
}
this.downloading = true
try {
// 下载模板文件
const response = await this.$axios({
url: this.template_url,
method: "GET",
responseType: "blob"
})
// 创建下载链接
const blob = new Blob([response.data])
const url = window.URL.createObjectURL(blob)
// 获取文件名,从Content-Disposition中提取或使用默认名称
let filename = "导入模板.xlsx"
const disposition = response.headers["content-disposition"]
if (disposition && disposition.indexOf("attachment") !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
const matches = filenameRegex.exec(disposition)
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, "")
// 解决中文乱码问题
try {
filename = decodeURIComponent(filename)
} catch (e) {
// 解码失败时使用原始文件名
}
}
}
// 触发下载
const link = document.createElement("a")
link.href = url
link.download = filename
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$message.success("模板下载成功!")
} catch (error) {
this.$message.error("模板下载失败:" + (error.message || "未知错误"))
this.$emit("template-error", error)
} finally {
this.downloading = false
}
},
readExcelFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result)
const workbook = XLSX.read(data, { type: "array" })
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 })
resolve(jsonData)
} catch (error) {
reject(new Error("文件解析失败"))
}
}
reader.onerror = () => reject(new Error("文件读取失败"))
reader.readAsArrayBuffer(file)
})
},
async processData(data) {
this.excelData = data
this.stats.totalRecords = data.length - 1 // 减去表头
this.errorDetails = []
let successCount = 0
let failedCount = 0
// 模拟数据处理过程
for (let i = 1; i < data.length; i++) {
await this.updateProgress(40 + Math.round((i / (data.length - 1)) * 50), `处理第 ${i} 条记录...`)
// 模拟数据验证和处理
const row = data[i]
const isValid = this.validateRow(row, i + 1)
if (isValid) {
successCount++
} else {
failedCount++
}
// 添加延迟以显示进度效果
if (i % 10 === 0) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
this.stats.successCount = successCount
this.stats.failedCount = failedCount
},
validateRow(row, rowIndex) {
// 模拟数据验证逻辑
if (!row || row.length === 0) {
this.errorDetails.push({
row: rowIndex,
message: "空行数据"
})
return false
}
// 随机生成一些错误(用于演示)
if (Math.random() < 0.1) {
// 10% 的错误率
const errors = ["必填字段为空", "数据格式不正确", "数据长度超出限制", "重复数据", "外键约束违反"]
this.errorDetails.push({
row: rowIndex,
message: errors[Math.floor(Math.random() * errors.length)]
})
return false
}
return true
},
updateProgress(percentage, text) {
return new Promise((resolve) => {
setTimeout(() => {
this.progress = percentage
this.progressText = text
resolve()
}, 100)
})
},
getSuccessRateColor(rate) {
if (rate >= 90) return "#67C23A"
if (rate >= 70) return "#E6A23C"
return "#F56C6C"
},
formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
},
downloadReport() {
if (this.errorDetails.length === 0) return
let content = "错误报告\n\n"
content += `总记录数: ${this.stats.totalRecords}\n`
content += `成功数: ${this.stats.successCount}\n`
content += `失败数: ${this.stats.failedCount}\n`
content += `成功率: ${this.successRate}%\n\n`
content += "错误详情:\n"
this.errorDetails.forEach((error) => {
content += `${error.row}行: ${error.message}\n`
})
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "导入错误报告.txt"
a.click()
window.URL.revokeObjectURL(url)
this.$message.success("错误报告下载完成!")
},
resetUpload() {
this.selectedFile = null
this.importing = false
this.importComplete = false
this.progress = 0
this.progressText = ""
this.resetStats()
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
this.$emit("reset")
},
resetStats() {
this.stats = {
totalRecords: 0,
successCount: 0,
failedCount: 0
}
this.errorDetails = []
this.excelData = []
},
closeDialog() {
if (this.mode === "dialog") {
this.dialogVisible = false
}
},
showDialog() {
if (this.mode === "dialog") {
this.dialogVisible = true
}
}
}
}
</script>
<template>
<!-- Dialog模式 -->
<el-dialog
v-if="mode === 'dialog'"
:visible.sync="dialogVisible"
:title="title"
:width="width"
@close="resetUpload"
:append-to-body="true"
:destroy-on-close="true"
>
<div class="excel-import-container dialog-mode">
<div class="content">
<!-- 文件上传区域 -->
<div class="upload-area" v-if="!importing && !importComplete">
<el-upload
ref="upload"
action="#"
:auto-upload="false"
:on-change="handleFileChange"
:before-upload="beforeUpload"
accept=".xlsx,.xls"
drag
:show-file-list="false"
>
<div class="upload-icon">📁</div>
<div class="upload-text"> Excel 文件拖拽到此处或点击选择文件</div>
<div class="upload-hint">支持 .xlsx.xls 格式文件大小不超过 10MB</div>
</el-upload>
</div>
<!-- 下载模板按钮 -->
<div v-if="showTemplateBtn && !importing && !importComplete" class="template-download">
<el-button type="text" @click="downloadTemplate" :loading="downloading" icon="el-icon-download">下载导入模板</el-button>
</div>
<!-- 已选择文件信息 -->
<div v-if="selectedFile && !importing && !importComplete" class="file-info">
<div class="file-info-item">
<span><strong>文件名:</strong></span>
<span>{{ selectedFile.name }}</span>
</div>
<div class="file-info-item">
<span><strong>文件大小:</strong></span>
<span>{{ formatFileSize(selectedFile.size) }}</span>
</div>
<div class="file-info-item">
<span><strong>文件类型:</strong></span>
<span>{{ selectedFile.type || "未知" }}</span>
</div>
</div>
<!-- 导入按钮 -->
<div v-if="selectedFile && !importing && !importComplete" class="actions">
<el-button type="primary" size="large" @click="startImport" :loading="importing">
<i class="el-icon-upload2"></i>
开始导入
</el-button>
<el-button size="large" @click="resetUpload">
<i class="el-icon-refresh"></i>
重新选择
</el-button>
</div>
<!-- 导入进度 -->
<div v-if="importing" class="progress-section">
<h3 style="text-align: center; color: #409eff; margin-bottom: 20px">
<i class="el-icon-loading"></i>
正在处理文件...
</h3>
<el-progress :percentage="progress" :color="progressColor" :stroke-width="8" text-inside></el-progress>
<p style="text-align: center; margin-top: 15px; color: #606266">
{{ progressText }}
</p>
</div>
<!-- 导入结果 -->
<div v-if="importComplete" class="result-section">
<div class="result-header">
<div class="result-icon">
<i class="el-icon-circle-check"></i>
</div>
<h3>导入完成</h3>
</div>
<div class="stats-container">
<div class="stat-card">
<div class="stat-icon total-icon">
<i class="el-icon-document"></i>
</div>
<div class="stat-content">
<div class="stat-number total">{{ stats.totalRecords }}</div>
<div class="stat-label">总记录数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success-icon">
<i class="el-icon-check"></i>
</div>
<div class="stat-content">
<div class="stat-number success">{{ stats.successCount }}</div>
<div class="stat-label">导入成功</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon failed-icon">
<i class="el-icon-close"></i>
</div>
<div class="stat-content">
<div class="stat-number failed">{{ stats.failedCount }}</div>
<div class="stat-label">导入失败</div>
</div>
</div>
</div>
<!-- 成功率展示 -->
<div class="success-rate-section">
<div class="success-rate-header">
<span class="success-rate-label">导入成功率</span>
<span class="success-rate-value" :style="{ color: getSuccessRateColor(successRate) }">{{ successRate }}%</span>
</div>
<el-progress :percentage="successRate" :color="getSuccessRateColor(successRate)" :stroke-width="6"></el-progress>
</div>
<!-- 错误详情 -->
<div v-if="errorDetails.length > 0" class="error-details-section">
<el-collapse>
<el-collapse-item name="errors">
<template slot="title">
<div class="error-title">
<i class="el-icon-warning-outline"></i>
<span>查看错误详情 ({{ errorDetails.length }})</span>
</div>
</template>
<div class="error-list">
<div v-for="(error, index) in errorDetails" :key="index" class="error-item">
<div class="error-row"> {{ error.row }} </div>
<div class="error-message">{{ error.errMsg }}</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<!-- 操作按钮 -->
<div class="actions">
<el-button type="primary" @click="downloadReport" v-if="stats.failedCount > 0">
<i class="el-icon-download"></i>
下载错误报告
</el-button>
<el-button @click="resetUpload">
<i class="el-icon-refresh"></i>
重新导入
</el-button>
<el-button @click="closeDialog">
<i class="el-icon-close"></i>
关闭
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 直接显示模式 -->
<div v-else class="excel-import-container">
<div class="header">
<h1>📊 Excel 文件导入</h1>
<p>支持 .xlsx.xls 格式文件自动解析并统计导入结果</p>
</div>
<div class="content">
<!-- 文件上传区域 -->
<div class="upload-area" v-if="!importing && !importComplete">
<el-upload
ref="upload"
action="#"
:auto-upload="false"
:on-change="handleFileChange"
:before-upload="beforeUpload"
accept=".xlsx,.xls"
drag
:show-file-list="false"
>
<div class="upload-icon">📁</div>
<div class="upload-text"> Excel 文件拖拽到此处或点击选择文件</div>
<div class="upload-hint">支持 .xlsx.xls 格式文件大小不超过 10MB</div>
</el-upload>
</div>
<!-- 下载模板按钮 -->
<div v-if="showTemplateBtn && !importing && !importComplete" class="template-download">
<el-button type="text" @click="downloadTemplate" :loading="downloading" icon="el-icon-download">下载导入模板</el-button>
</div>
<!-- 已选择文件信息 -->
<div v-if="selectedFile && !importing && !importComplete" class="file-info">
<div class="file-info-item">
<span><strong>文件名:</strong></span>
<span>{{ selectedFile.name }}</span>
</div>
<div class="file-info-item">
<span><strong>文件大小:</strong></span>
<span>{{ formatFileSize(selectedFile.size) }}</span>
</div>
<div class="file-info-item">
<span><strong>文件类型:</strong></span>
<span>{{ selectedFile.type || "未知" }}</span>
</div>
</div>
<!-- 导入按钮 -->
<div v-if="selectedFile && !importing && !importComplete" class="actions">
<el-button type="primary" size="large" @click="startImport" :loading="importing">
<i class="el-icon-upload2"></i>
开始导入
</el-button>
<el-button size="large" @click="resetUpload">
<i class="el-icon-refresh"></i>
重新选择
</el-button>
</div>
<!-- 导入进度 -->
<div v-if="importing" class="progress-section">
<h3 style="text-align: center; color: #409eff; margin-bottom: 20px">
<i class="el-icon-loading"></i>
正在处理文件...
</h3>
<el-progress :percentage="progress" :color="progressColor" :stroke-width="8" text-inside></el-progress>
<p style="text-align: center; margin-top: 15px; color: #606266">
{{ progressText }}
</p>
</div>
<!-- 导入结果 -->
<div v-if="importComplete" class="result-section">
<div class="result-header">
<div class="result-icon">
<i class="el-icon-circle-check"></i>
</div>
<h3>导入完成</h3>
</div>
<div class="stats-container">
<div class="stat-card">
<div class="stat-icon total-icon">
<i class="el-icon-document"></i>
</div>
<div class="stat-content">
<div class="stat-number total">{{ stats.totalRecords }}</div>
<div class="stat-label">总记录数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success-icon">
<i class="el-icon-check"></i>
</div>
<div class="stat-content">
<div class="stat-number success">{{ stats.successCount }}</div>
<div class="stat-label">导入成功</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon failed-icon">
<i class="el-icon-close"></i>
</div>
<div class="stat-content">
<div class="stat-number failed">{{ stats.failedCount }}</div>
<div class="stat-label">导入失败</div>
</div>
</div>
</div>
<!-- 错误详情 -->
<div v-if="errorDetails.length > 0" class="error-details-section">
<el-collapse>
<el-collapse-item name="errors">
<template slot="title">
<div class="error-title">
<i class="el-icon-warning-outline"></i>
<span>查看错误详情 ({{ errorDetails.length }})</span>
</div>
</template>
<div class="error-list">
<div v-for="(error, index) in errorDetails" :key="index" class="error-item">
<div class="error-row"> {{ error.row }} </div>
<div class="error-message">{{ error.message }}</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<!-- 操作按钮 -->
<div class="actions">
<el-button type="primary" @click="downloadReport" v-if="stats.failedCount > 0">
<i class="el-icon-download"></i>
下载错误报告
</el-button>
<el-button @click="resetUpload">
<i class="el-icon-refresh"></i>
重新导入
</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.excel-import-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.excel-import-container.dialog-mode {
max-width: 100%;
margin: 0;
box-shadow: none;
border-radius: 0;
}
.header {
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 300;
}
.header p {
margin: 10px 0 0 0;
opacity: 0.9;
font-size: 14px;
}
.content {
padding: 40px;
}
.template-download {
text-align: center;
margin-bottom: 20px;
}
.upload-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 40px;
text-align: center;
background: #fafafa;
transition: all 0.3s ease;
margin-bottom: 15px;
}
.upload-area:hover {
border-color: #409eff;
background: #f0f9ff;
}
.upload-icon {
font-size: 48px;
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
color: #606266;
font-size: 16px;
margin-bottom: 8px;
}
.upload-hint {
color: #909399;
font-size: 14px;
}
.progress-section {
margin: 30px 0;
}
.stats-container {
display: flex;
justify-content: space-between;
gap: 15px;
margin-top: 20px;
}
.stat-card {
flex: 1;
background: linear-gradient(135deg, #f6f8fa 0%, #ffffff 100%);
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 15px;
transition: transform 0.2s ease;
display: flex;
align-items: center;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.total-icon {
background-color: rgba(64, 158, 255, 0.1);
color: #409eff;
}
.success-icon {
background-color: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
.failed-icon {
background-color: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
.stat-content {
text-align: left;
}
.stat-number {
font-size: 24px;
font-weight: bold;
line-height: 1;
margin-bottom: 4px;
}
.total {
color: #409eff;
}
.success {
color: #67c23a;
}
.failed {
color: #f56c6c;
}
.stat-label {
color: #606266;
font-size: 12px;
}
.result-section {
margin-top: 30px;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
}
.result-header {
text-align: center;
margin-bottom: 30px;
}
.result-icon {
font-size: 48px;
color: #67c23a;
margin-bottom: 10px;
}
.result-header h3 {
font-size: 20px;
font-weight: 500;
margin: 0;
color: #67c23a;
}
.success-rate-section {
margin-top: 30px;
background: #f9f9f9;
border-radius: 8px;
padding: 20px;
}
.success-rate-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.success-rate-label {
font-weight: 500;
font-size: 14px;
color: #606266;
}
.success-rate-value {
font-weight: bold;
font-size: 18px;
}
.error-details-section {
margin-top: 20px;
}
.error-title {
display: flex;
align-items: center;
color: #f56c6c;
font-weight: 500;
}
.error-title i {
margin-right: 8px;
font-size: 16px;
}
.error-list {
max-height: 250px;
overflow-y: auto;
background: #fff5f5;
border-radius: 4px;
}
.error-item {
padding: 12px;
border-bottom: 1px solid #fde2e2;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
}
.error-item:last-child {
border-bottom: none;
}
.error-row {
color: #f56c6c;
font-weight: bold;
background: rgba(245, 108, 108, 0.1);
padding: 2px 8px;
border-radius: 4px;
margin-right: 8px;
}
.error-message {
color: #606266;
flex: 1;
}
.file-info {
background: #e8f4fd;
border: 1px solid #b3d8ff;
border-radius: 6px;
padding: 16px;
margin-bottom: 20px;
}
.file-info-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.file-info-item:last-child {
margin-bottom: 0;
}
.actions {
text-align: center;
margin-top: 30px;
}
</style>