..
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user