feat: 实现异步文件上传前端功能

- 添加批量上传API接口
- 扩展UploadData组件,添加批量上传弹窗
- 添加统计卡片展示(上传中、解析中、成功、失败)
- 添加文件上传记录列表
- 实现轮询机制自动刷新状态
- 支持文件数量、格式、大小校验
- 支持手动刷新和状态筛选
- 添加响应式布局支持
This commit is contained in:
wkc
2026-03-05 14:21:33 +08:00
parent 1a9ca2a05f
commit b52d6c6e7a
2 changed files with 690 additions and 3 deletions

View File

@@ -79,3 +79,63 @@ export function getImportStatus(taskId) {
method: 'get' method: 'get'
}) })
} }
// ========== 批量文件上传相关接口 ==========
/**
* 批量上传文件
* @param {Number} projectId 项目ID
* @param {Array<File>} files 文件数组
* @returns {Promise} 返回 batchId
*/
export function batchUploadFiles(projectId, files) {
const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
formData.append('projectId', projectId)
return request({
url: '/ccdi/file-upload/batch',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 300000 // 5分钟超时
})
}
/**
* 查询文件上传记录列表
* @param {Object} params 查询参数
*/
export function getFileUploadList(params) {
return request({
url: '/ccdi/file-upload/list',
method: 'get',
params
})
}
/**
* 查询文件上传统计
* @param {Number} projectId 项目ID
*/
export function getFileUploadStatistics(projectId) {
return request({
url: `/ccdi/file-upload/statistics/${projectId}`,
method: 'get'
})
}
/**
* 查询文件上传详情
* @param {Number} id 记录ID
*/
export function getFileUploadDetail(id) {
return request({
url: `/ccdi/file-upload/detail/${id}`,
method: 'get'
})
}

View File

@@ -46,6 +46,126 @@
</div> </div>
</div> </div>
<!-- 统计卡片区域 -->
<div class="statistics-section">
<div class="stat-card" @click="handleStatusFilter('uploading')">
<div class="stat-icon uploading">
<i class="el-icon-upload"></i>
</div>
<div class="stat-content">
<div class="stat-label">上传中</div>
<div class="stat-value">{{ statistics.uploading }}</div>
</div>
</div>
<div class="stat-card" @click="handleStatusFilter('parsing')">
<div class="stat-icon parsing">
<i class="el-icon-loading"></i>
</div>
<div class="stat-content">
<div class="stat-label">解析中</div>
<div class="stat-value">{{ statistics.parsing }}</div>
</div>
</div>
<div class="stat-card" @click="handleStatusFilter('parsed_success')">
<div class="stat-icon success">
<i class="el-icon-success"></i>
</div>
<div class="stat-content">
<div class="stat-label">解析成功</div>
<div class="stat-value">{{ statistics.parsed_success }}</div>
</div>
</div>
<div class="stat-card" @click="handleStatusFilter('parsed_failed')">
<div class="stat-icon failed">
<i class="el-icon-error"></i>
</div>
<div class="stat-content">
<div class="stat-label">解析失败</div>
<div class="stat-value">{{ statistics.parsed_failed }}</div>
</div>
</div>
</div>
<!-- 文件上传记录列表 -->
<div class="file-list-section">
<div class="list-toolbar">
<div class="filter-group">
<el-select
v-model="queryParams.fileStatus"
placeholder="文件状态"
clearable
@change="loadFileList"
style="width: 150px"
>
<el-option label="上传中" value="uploading"></el-option>
<el-option label="解析中" value="parsing"></el-option>
<el-option label="解析成功" value="parsed_success"></el-option>
<el-option label="解析失败" value="parsed_failed"></el-option>
</el-select>
</div>
<el-button icon="el-icon-refresh" @click="handleManualRefresh">刷新</el-button>
</div>
<el-table :data="fileUploadList" v-loading="listLoading" stripe border>
<el-table-column prop="fileName" label="文件名" min-width="200"></el-table-column>
<el-table-column prop="fileSize" label="文件大小" width="120">
<template slot-scope="scope">
{{ formatFileSize(scope.row.fileSize) }}
</template>
</el-table-column>
<el-table-column prop="fileStatus" label="状态" width="120">
<template slot-scope="scope">
<el-tag :type="getStatusType(scope.row.fileStatus)" size="small">
{{ getStatusText(scope.row.fileStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="enterpriseNames" label="主体名称" min-width="150">
<template slot-scope="scope">
{{ scope.row.enterpriseNames || '-' }}
</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="160"></el-table-column>
<el-table-column prop="uploadUser" label="上传人" width="100"></el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.fileStatus === 'parsed_success'"
type="text"
size="small"
@click="handleViewFlow(scope.row)"
>
查看流水
</el-button>
<el-button
v-if="scope.row.fileStatus === 'parsed_failed'"
type="text"
size="small"
@click="handleViewError(scope.row)"
>
查看错误
</el-button>
<span v-if="scope.row.fileStatus === 'uploading' || scope.row.fileStatus === 'parsing'">
-
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
@current-change="handlePageChange"
:current-page="queryParams.pageNum"
:page-size="queryParams.pageSize"
:total="total"
layout="total, prev, pager, next, jumper"
style="margin-top: 16px; text-align: right"
></el-pagination>
</div>
<!-- 数据质量检查 <!-- 数据质量检查
<div class="quality-check-section"> <div class="quality-check-section">
<div class="section-header"> <div class="section-header">
@@ -149,6 +269,63 @@
> >
</span> </span>
</el-dialog> </el-dialog>
<!-- 批量上传弹窗 -->
<el-dialog
title="批量上传流水文件"
:visible.sync="batchUploadDialogVisible"
:close-on-click-modal="false"
width="700px"
>
<el-upload
class="batch-upload-area"
drag
action="#"
multiple
:auto-upload="false"
:on-change="handleBatchFileChange"
:file-list="selectedFiles"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">
支持 .xlsx, .xls 格式文件最多100个文件单个文件不超过50MB
</div>
</el-upload>
<div v-if="selectedFiles.length > 0" class="selected-files">
<div class="files-header">
<span>已选择 {{ selectedFiles.length }} 个文件</span>
</div>
<div class="files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="file-item"
>
<i class="el-icon-document"></i>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<el-button
type="text"
icon="el-icon-close"
@click="handleRemoveFile(index)"
></el-button>
</div>
</div>
</div>
<span slot="footer">
<el-button @click="batchUploadDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="uploadLoading"
:disabled="selectedFiles.length === 0"
@click="handleBatchUpload"
>开始上传</el-button
>
</span>
</el-dialog>
</div> </div>
</template> </template>
@@ -160,6 +337,9 @@ import {
pullBankInfo, pullBankInfo,
updateNameListSelection, updateNameListSelection,
uploadFile, uploadFile,
batchUploadFiles,
getFileUploadList,
getFileUploadStatistics,
} from "@/api/ccdiProjectUpload"; } from "@/api/ccdiProjectUpload";
export default { export default {
@@ -261,6 +441,34 @@ export default {
level: "info", level: "info",
}, },
], ],
// === 批量上传相关 ===
batchUploadDialogVisible: false,
selectedFiles: [],
uploadLoading: false,
// === 统计数据 ===
statistics: {
uploading: 0,
parsing: 0,
parsed_success: 0,
parsed_failed: 0,
},
// === 文件列表相关 ===
fileUploadList: [],
listLoading: false,
queryParams: {
projectId: null,
fileStatus: null,
pageNum: 1,
pageSize: 20,
},
total: 0,
// === 轮询相关 ===
pollingTimer: null,
pollingEnabled: false,
pollingInterval: 5000,
}; };
}, },
created() { created() {
@@ -272,6 +480,20 @@ export default {
mounted() { mounted() {
// 组件挂载后监听项目ID变化 // 组件挂载后监听项目ID变化
this.$watch("projectId", this.loadInitialData); this.$watch("projectId", this.loadInitialData);
// 加载统计数据和文件列表
this.loadStatistics();
this.loadFileList();
// 检查是否需要启动轮询
this.$nextTick(() => {
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
this.startPolling();
}
});
},
beforeDestroy() {
this.stopPolling();
}, },
methods: { methods: {
/** 加载初始数据 */ /** 加载初始数据 */
@@ -361,13 +583,19 @@ export default {
const card = this.uploadCards.find((c) => c.key === key); const card = this.uploadCards.find((c) => c.key === key);
if (!card) return; if (!card) return;
if (key === "namelist") { if (key === "transaction") {
this.showNameListDialog = true; // 流水导入 - 打开批量上传弹窗
} else { this.batchUploadDialogVisible = true;
this.selectedFiles = [];
} else if (key === "credit") {
// 征信导入 - 保持现有逻辑
this.uploadFileType = key; this.uploadFileType = key;
this.uploadDialogTitle = `上传${card.title}`; this.uploadDialogTitle = `上传${card.title}`;
this.uploadFileTypes = card.desc.replace(/.*支持|上传/g, "").trim(); this.uploadFileTypes = card.desc.replace(/.*支持|上传/g, "").trim();
this.showUploadDialog = true; this.showUploadDialog = true;
} else if (key === "namelist") {
// 名单库选择 - 保持现有逻辑
this.showNameListDialog = true;
} }
}, },
/** 文件选择变化 */ /** 文件选择变化 */
@@ -601,6 +829,223 @@ export default {
}; };
return statusMap[status] || "未知"; return statusMap[status] || "未知";
}, },
// === 批量上传相关方法 ===
/** 批量上传的文件选择变化 */
handleBatchFileChange(file, fileList) {
if (fileList.length > 100) {
this.$message.warning("最多上传100个文件");
fileList = fileList.slice(0, 100);
}
const validTypes = [".xlsx", ".xls"];
const invalidFiles = fileList.filter((f) => {
const ext = f.name.substring(f.name.lastIndexOf(".")).toLowerCase();
return !validTypes.includes(ext);
});
if (invalidFiles.length > 0) {
this.$message.error("仅支持 .xlsx, .xls 格式文件");
return;
}
const oversizedFiles = fileList.filter((f) => f.size > 50 * 1024 * 1024);
if (oversizedFiles.length > 0) {
this.$message.error("单个文件不能超过50MB");
return;
}
this.selectedFiles = fileList;
},
/** 删除已选文件 */
handleRemoveFile(index) {
this.selectedFiles.splice(index, 1);
},
/** 开始批量上传 */
async handleBatchUpload() {
if (this.selectedFiles.length === 0) {
this.$message.warning("请选择要上传的文件");
return;
}
this.uploadLoading = true;
try {
const res = await batchUploadFiles(
this.projectId,
this.selectedFiles.map((f) => f.raw)
);
this.uploadLoading = false;
this.batchUploadDialogVisible = false;
this.$message.success("上传任务已提交,请查看处理进度");
// 刷新数据并启动轮询
await Promise.all([this.loadStatistics(), this.loadFileList()]);
this.startPolling();
} catch (error) {
this.uploadLoading = false;
this.$message.error("上传失败:" + (error.msg || "未知错误"));
}
},
// === 统计和列表相关方法 ===
/** 加载统计数据 */
async loadStatistics() {
try {
const res = await getFileUploadStatistics(this.projectId);
this.statistics = res.data || {
uploading: 0,
parsing: 0,
parsed_success: 0,
parsed_failed: 0,
};
} catch (error) {
console.error("加载统计数据失败:", error);
}
},
/** 加载文件列表 */
async loadFileList() {
this.listLoading = true;
try {
const params = {
projectId: this.projectId,
fileStatus: this.queryParams.fileStatus,
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize,
};
const res = await getFileUploadList(params);
this.fileUploadList = res.rows || [];
this.total = res.total || 0;
} catch (error) {
this.$message.error("加载文件列表失败");
console.error(error);
} finally {
this.listLoading = false;
}
},
// === 轮询相关方法 ===
/** 启动轮询 */
startPolling() {
if (this.pollingEnabled) return;
this.pollingEnabled = true;
const poll = () => {
if (!this.pollingEnabled) return;
Promise.all([this.loadStatistics(), this.loadFileList()])
.then(() => {
if (
this.statistics.uploading === 0 &&
this.statistics.parsing === 0
) {
this.stopPolling();
return;
}
this.pollingTimer = setTimeout(poll, this.pollingInterval);
})
.catch((error) => {
console.error("轮询失败:", error);
this.pollingTimer = setTimeout(poll, this.pollingInterval);
});
};
poll();
},
/** 停止轮询 */
stopPolling() {
this.pollingEnabled = false;
if (this.pollingTimer) {
clearTimeout(this.pollingTimer);
this.pollingTimer = null;
}
},
/** 手动刷新 */
async handleManualRefresh() {
await Promise.all([this.loadStatistics(), this.loadFileList()]);
this.$message.success("刷新成功");
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
this.startPolling();
}
},
// === 辅助方法 ===
/** 状态筛选 */
handleStatusFilter(status) {
this.queryParams.fileStatus = status;
this.queryParams.pageNum = 1;
this.loadFileList();
},
/** 分页变化 */
handlePageChange(pageNum) {
this.queryParams.pageNum = pageNum;
this.loadFileList();
},
/** 查看流水 */
handleViewFlow(record) {
this.$emit("menu-change", {
key: "detail",
route: "detail",
params: { logId: record.logId },
});
},
/** 查看错误 */
handleViewError(record) {
this.$alert(record.errorMessage || "未知错误", "错误信息", {
confirmButtonText: "确定",
type: "error",
});
},
/** 状态文本映射 */
getStatusText(status) {
const map = {
uploading: "上传中",
parsing: "解析中",
parsed_success: "解析成功",
parsed_failed: "解析失败",
};
return map[status] || status;
},
/** 状态标签类型映射 */
getStatusType(status) {
const map = {
uploading: "primary",
parsing: "warning",
parsed_success: "success",
parsed_failed: "danger",
};
return map[status] || "info";
},
/** 格式化文件大小 */
formatFileSize(bytes) {
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];
},
}, },
}; };
</script> </script>
@@ -876,6 +1321,97 @@ export default {
} }
} }
// 统计卡片区域
.statistics-section {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
.stat-card {
background: #fff;
border-radius: 4px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&.uploading {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
&.parsing {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
&.success {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.failed {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
}
.stat-content {
flex: 1;
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
}
}
}
// 文件列表区域
.file-list-section {
background: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.list-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.filter-group {
display: flex;
gap: 12px;
}
}
}
// 上传弹窗样式 // 上传弹窗样式
::v-deep .el-dialog__wrapper { ::v-deep .el-dialog__wrapper {
.upload-area { .upload-area {
@@ -898,6 +1434,83 @@ export default {
} }
} }
// 批量上传弹窗样式
.batch-upload-area {
width: 100%;
::v-deep .el-upload {
width: 100%;
.el-upload-dragger {
width: 100%;
height: 180px;
}
}
}
.selected-files {
margin-top: 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
.files-header {
padding: 12px 16px;
background: #f5f7fa;
border-bottom: 1px solid #ebeef5;
font-size: 14px;
font-weight: 500;
color: #606266;
}
.files-list {
padding: 8px;
.file-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
transition: background 0.3s;
&:hover {
background: #f5f7fa;
}
i {
font-size: 18px;
color: #1890ff;
margin-right: 8px;
}
.file-name {
flex: 1;
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #909399;
margin: 0 12px;
}
.el-button {
padding: 4px;
color: #909399;
&:hover {
color: #f56c6c;
}
}
}
}
}
// 响应式 // 响应式
@media (max-width: 1200px) { @media (max-width: 1200px) {
.upload-section .upload-cards { .upload-section .upload-cards {
@@ -908,6 +1521,10 @@ export default {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
} }
.statistics-section {
grid-template-columns: repeat(2, 1fr);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -932,5 +1549,15 @@ export default {
.quality-check-section .metrics { .quality-check-section .metrics {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.statistics-section {
grid-template-columns: 1fr;
}
.file-list-section .list-toolbar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
} }
</style> </style>