Kaynağa Gözat

feat: 添加xlsx库以支持Excel文件导入功能,更新请求超时时间以提高稳定性,简化导入数据步骤,优化用户体验。

master
lizhuang 3 gün önce
ebeveyn
işleme
228d5e1381

+ 2
- 1
package.json Dosyayı Görüntüle

@@ -57,7 +57,8 @@
"vue-router": "3.4.9",
"vuedraggable": "2.24.3",
"vuex": "3.6.0",
"workflow-bpmn-modeler": "^0.2.8"
"workflow-bpmn-modeler": "^0.2.8",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "4.4.6",

+ 1
- 1
src/utils/request.js Dosyayı Görüntüle

@@ -19,7 +19,7 @@ const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 300000
timeout: 500000
})

// request拦截器

+ 204
- 435
src/views/sales-analysis/analysis-data/ImportData.vue Dosyayı Görüntüle

@@ -7,66 +7,20 @@
align-center
class="steps-container"
>
<el-step title="选择来源" description="选择导入数据类型"></el-step>
<el-step title="选择配置" description="选择分类或店铺"></el-step>
<el-step title="选择配置" description="选择分类"></el-step>
<el-step title="上传文件" description="上传Excel数据文件"></el-step>
<el-step title="预览数据" description="查看处理结果"></el-step>
<el-step title="保存入库" description="确认并保存数据"></el-step>
</el-steps>

<!-- 第一步:选择数据来源 -->
<!-- 第一步:选择分类 -->
<div v-if="currentStep === 0" class="step-wrapper">
<div class="step-header">
<h2>选择数据来源</h2>
<p>不同的数据来源有不同的处理方式和字段要求。</p>
</div>
<div class="source-options">
<div
class="source-option"
:class="{ active: selectedDataSource === 'logistics' }"
@click="selectDataSource('logistics')"
>
<i class="el-icon-truck source-icon"></i>
<div class="source-info">
<h3>物流数据来源</h3>
<p>导入物流出库数据</p>
</div>
</div>
<div
class="source-option"
:class="{ active: selectedDataSource === 'fba' }"
@click="selectDataSource('fba')"
>
<i class="el-icon-box source-icon"></i>
<div class="source-info">
<h3>FBA数据来源</h3>
<p>导入Amazon FBA出库数据</p>
</div>
</div>
</div>
<div class="step-actions">
<el-button
type="primary"
:disabled="!selectedDataSource"
@click="nextStep"
>
下一步
</el-button>
</div>
</div>

<!-- 第二步:选择配置 -->
<div v-if="currentStep === 1" class="step-wrapper">
<div class="step-header">
<h2 v-if="selectedDataSource === 'logistics'">选择分类</h2>
<h2 v-if="selectedDataSource === 'fba'">选择店铺</h2>
<h2>选择分类</h2>
</div>

<!-- 物流数据来源:选择分类 -->
<div v-if="selectedDataSource === 'logistics'" class="category-selection">
<!-- 选择分类 -->
<div class="category-selection">
<el-alert
title="请选择数据分类"
description="选择分类后,系统会根据分类的字段配置来匹配和处理导入的基准数据。"
type="info"
show-icon
:closable="false"
@@ -98,40 +52,10 @@
</div>
</div>

<!-- FBA数据来源:选择店铺 -->
<div v-if="selectedDataSource === 'fba'" class="shop-selection">
<el-alert
title="请选择店铺"
description="选择店铺后,系统会根据店铺客户关联关系来匹配客户信息。"
type="info"
show-icon
:closable="false"
/>

<div class="shop-list" v-loading="shopsLoading">
<div
v-for="shop in fbaShopsList"
:key="shop.shopName"
class="shop-item"
:class="{ active: selectedShopName === shop.shopName }"
@click="selectShop(shop.shopName)"
>
<div class="shop-name">{{ shop.shopName }}</div>
<div class="shop-customer">
客户:{{ shop.customerName || "未关联" }}
</div>
</div>
</div>
</div>

<div class="step-actions">
<el-button @click="prevStep">上一步</el-button>
<el-button
type="primary"
:disabled="
(selectedDataSource === 'logistics' && !selectedCategoryId) ||
(selectedDataSource === 'fba' && !selectedShopName)
"
:disabled="!selectedCategoryId"
@click="nextStep"
>
下一步
@@ -139,8 +63,8 @@
</div>
</div>

<!-- 第步:上传文件 -->
<div v-if="currentStep === 2" class="step-wrapper">
<!-- 第步:上传文件 -->
<div v-if="currentStep === 1" class="step-wrapper">
<div class="step-header">
<h2>上传Excel文件</h2>
</div>
@@ -148,35 +72,11 @@
<div class="file-upload">
<!-- 物流数据文件格式要求 -->
<el-alert
v-if="selectedDataSource === 'logistics'"
title="物流数据文件格式要求"
:title="`当前选择分类是:${selectedCategory.name}`"
type="warning"
show-icon
:closable="false"
>
<p style="color: #303030;">当前选择分类是:<u>{{ selectedCategory.name }}</u>,请确保Excel文件包含以下列:</p>
<p style="color: #303030;">
<strong
>出库日期、目的地、店铺名称、出库类型、发送方式、发送番号、注文番号、商品编号、商品名称、数量、单价、送料、代引、客户名称、备注</strong
>
</p>
</el-alert>

<!-- FBA数据文件格式要求 -->
<el-alert
v-if="selectedDataSource === 'fba'"
title="FBA数据文件格式要求"
type="warning"
show-icon
:closable="false"
>
<p style="color: #303030;">当前选择的店铺是:<u>{{ selectedShopName }}</u>,请确保Excel文件包含以下列:</p>
<p style="color: #303030;">
<strong
>出荷日、出品者SKU、FNSKU、ASIN、FC、数量、Amazon注文番号、通貨、商品金額(商品1点ごと)、配送料、ギフト包装手数料、配送先(市区町村)、都道府県名、配送先(郵便番号)、付与されたAmazon
ポイント</strong
>
</p>
</el-alert>

<el-upload
@@ -208,228 +108,21 @@
<el-button @click="prevStep">上一步</el-button>
<el-button
type="primary"
@click="processBatchFiles"
:loading="processingFiles"
@click="saveData"
:loading="saveLoading"
:disabled="!fileList.length"
>
下一步,处理所有文件 ({{ fileList.length }})
</el-button>
</div>
</div>
</div>

<!-- 第四步:预览数据 -->
<div v-if="currentStep === 3" class="step-wrapper">
<div class="step-header">
<h2>预览处理结果</h2>
</div>

<div class="data-preview">
<!-- 处理结果统计 -->
<div class="statistics">
<div class="stat-card">
<div class="stat-number">{{ processResult.total || 0 }}</div>
<div class="stat-label">总计行数</div>
</div>
<div class="stat-card success">
<div class="stat-number">{{ processResult.processed || 0 }}</div>
<div class="stat-label">可处理</div>
</div>
<div class="stat-card warning">
<div class="stat-number">{{ processResult.filtered || 0 }}</div>
<div class="stat-label">已过滤</div>
</div>
<div class="stat-card danger">
<div class="stat-number">{{ processResult.errors || 0 }}</div>
<div class="stat-label">
错误数据<small
v-if="processResult.errors > 0"
@click="showErrorData"
>查看错误数据</small
>
</div>
</div>
</div>

<!-- 数据预览表格 -->
<div
class="data-table"
v-if="processResult.data && processResult.data.length > 0"
>
<h4>数据预览(前50条)</h4>
<el-table
:data="processResult.data.slice(0, 50)"
stripe
border
size="mini"
height="calc(100vh - 490px)"
>
<el-table-column
prop="rowNumber"
label="行号"
width="55"
align="center"
/>
<el-table-column
prop="date"
label="日期"
width="100"
></el-table-column>
<el-table-column prop="shopName" label="店铺"></el-table-column>
<el-table-column
prop="productCode"
label="商品编号"
></el-table-column>
<el-table-column
prop="productName"
label="商品名称"
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="customerName"
label="客户"
width="100"
></el-table-column>
<el-table-column
prop="category"
label="分类"
width="100"
></el-table-column>
<el-table-column
prop="quantity"
label="数量"
width="60"
></el-table-column>
<el-table-column
prop="totalAmount"
label="合计"
width="100"
></el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template slot-scope="scope">
<el-tag
:type="
scope.row.status === 1
? 'success'
: scope.row.status === 2
? 'warning'
: 'danger'
"
size="mini"
>
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="deliveryType"
label="出库类型"
width="120"
></el-table-column>
</el-table>
</div>

<div class="step-actions">
<el-button @click="prevStep">上一步</el-button>
<el-button
type="primary"
:disabled="!processResult.data || processResult.data.length === 0"
@click="nextStep"
>
确认保存
保存数据
</el-button>
</div>
</div>
</div>

<!-- 第五步:保存数据 -->
<div v-if="currentStep === 4" class="step-wrapper">
<div class="step-header">
<h2>保存数据</h2>
</div>

<div class="save-data">
<div v-if="saveLoading" class="saving">
<i class="el-icon-loading"></i>
<p>正在保存数据,请稍候...</p>
</div>

<div v-else-if="saveResult" class="save-success">
<i
class="el-icon-success"
style="color: #67c23a; font-size: 48px"
></i>
<h3>数据保存成功!</h3>

<div class="save-statistics">
<el-row :gutter="20">
<el-col :span="8">
<div class="stat-item">
<div class="stat-value">{{ saveResult.saved || 0 }}</div>
<div class="stat-label">总计保存</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat-item success">
<div class="stat-value">{{ saveResult.inserted || 0 }}</div>
<div class="stat-label">新增记录</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat-item warning">
<div class="stat-value">{{ saveResult.updated || 0 }}</div>
<div class="stat-label">更新记录</div>
</div>
</el-col>
</el-row>

<div v-if="saveResult.failed > 0" class="failed-info">
<el-alert
:title="`${saveResult.failed}条数据保存失败`"
type="error"
show-icon
:closable="false"
>
<div v-if="saveResult.errors && saveResult.errors.length > 0">
<p>错误详情:</p>
<ul>
<li v-for="error in saveResult.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</el-alert>
</div>
</div>

<div class="step-actions">
<el-button type="primary" @click="goToDataList"
>查看数据列表</el-button
>
<el-button @click="startOver">重新导入</el-button>
</div>
</div>
</div>
</div>

<el-dialog title="错误数据" :visible.sync="errorDataVisible" width="50%">
<ul class="error-list">
<li v-for="error in errorData" :key="error">
{{ error }}
</li>
</ul>
</el-dialog>
</div>
</template>

<script>
import {
getCategoriesSimple,
getShopCustomerList,
importAnalysisDataBatch,
importFBADataBatch,
saveImportedData,
} from "@/api/sales-analysis";
import { getCategoriesSimple, saveImportedData } from "@/api/sales-analysis";
import * as XLSX from "xlsx";

export default {
name: "ImportData",
@@ -470,15 +163,9 @@ export default {
categoryId: this.selectedCategoryId,
};
},
fbaShopsList() {
return this.shopsList.filter((item) => {
return item.shopName.includes("FBA");
});
},
},
mounted() {
this.fetchCategoriesList();
this.fetchShopsList();
},
methods: {
// 解析字段配置字符串为 JSON 对象
@@ -526,33 +213,6 @@ export default {
}
},

// 获取店铺列表
async fetchShopsList() {
try {
this.shopsLoading = true;
const response = await getShopCustomerList({
page: 1,
pageSize: 1000,
});

if (response.code === 200) {
this.shopsList = response.data.list || [];
} else {
this.$message.error(response.message || "获取店铺列表失败");
}
} catch (error) {
console.error("获取店铺列表失败:", error);
this.$message.error("获取店铺列表失败");
} finally {
this.shopsLoading = false;
}
},

// 选择店铺
selectShop(shopName) {
this.selectedShopName = shopName;
},

// 选择分类
selectCategory(category) {
this.selectedCategoryId = category.id;
@@ -561,12 +221,7 @@ export default {

// 下一步
nextStep() {
if (this.currentStep === 3) {
// 保存数据
this.saveData();
} else {
this.currentStep++;
}
this.currentStep++;
},

// 上一步
@@ -629,50 +284,6 @@ export default {
this.fileList = [];
},

// 批量处理文件
async processBatchFiles() {
if (this.fileList.length === 0) {
this.$message.warning("请先选择文件");
return;
}

try {
this.processingFiles = true;

const formData = new FormData();

// 根据数据来源添加不同的参数
if (this.selectedDataSource === "fba") {
formData.append("shopName", this.selectedShopName);
} else {
formData.append("categoryId", this.selectedCategoryId);
}

this.fileList.forEach((fileItem, index) => {
formData.append("files", fileItem.file);
});

// 根据数据来源调用不同的API
const response =
this.selectedDataSource === "fba"
? await importFBADataBatch(formData)
: await importAnalysisDataBatch(formData);

if (response.code === 200) {
this.processResult = response.data || {};
this.$message.success(`成功处理${this.fileList.length}个文件`);
this.currentStep = 3;
} else {
this.$message.error(response.message || "批量处理失败");
}
} catch (error) {
console.error("批量处理失败:", error);
this.$message.error("批量处理失败");
} finally {
this.processingFiles = false;
}
},

// 上传成功
onUploadSuccess(response) {
console.log("上传成功回调:", response);
@@ -704,51 +315,209 @@ export default {

// 保存数据
async saveData() {
console.log(this.selectedCategory);

try {
this.currentStep = 4;
this.saveLoading = true;
const response = await saveImportedData(this.processResult.data);

// 存储所有文件的解析数据
const allParsedData = [];

// 遍历文件列表
for (const fileItem of this.fileList) {
try {
// 读取文件内容
const reader = new FileReader();
const fileData = await new Promise((resolve, reject) => {
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(e);
reader.readAsArrayBuffer(fileItem.file);
});

// 解析Excel文件
const workbook = XLSX.read(fileData, { type: "array" });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];

// 将工作表转换为JSON数据
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
raw: false, // 将所有值转换为字符串
defval: "", // 空单元格的默认值
});

// 验证数据格式
if (jsonData.length < 2) {
throw new Error(`文件 ${fileItem.name} 数据行数不足`);
}

// 获取表头
const headers = jsonData[0];

// 验证字段配置
const fieldConfig = this.parseFieldConfig(
this.selectedCategory.fieldConfig
);
const requiredFields = fieldConfig
.filter((field) => field.required)
.map((field) => field.fieldName);

// 检查必填字段是否存在
const missingFields = requiredFields.filter(
(field) => !headers.includes(field)
);
if (missingFields.length > 0) {
throw new Error(
`文件 ${fileItem.name} 缺少必填字段: ${missingFields.join(
", "
)}`
);
}

// 处理数据行
const parsedRows = [];
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (row.length === 0 || row.every((cell) => !cell)) continue; // 跳过空行

// 创建符合目标结构的数据对象
const rowData = {
id: null,
date: "", // 默认当前日期,可以从Excel中获取
shopName: "",
productCode: "",
productName: "",
customerName: "",
category: this.selectedCategory.name,
categorySpecs: "",
quantity: 0,
totalAmount: "",
source: "",
status: 1,
deliveryType: "",
destination: "",
remarks: "",
orderNumber: "",
rowNumber: i + 1,
brand: "",
createdAt: null,
updatedAt: null,
};

const specs = {};

// 根据表头映射数据
headers.forEach((header, index) => {
const value = row[index] || "";
switch (header.toLowerCase()) {
case "日付":
// 转换日期格式 从 2025/7/18 到 2025-07-18
if (value) {
const parts = value.split("/");
if (parts.length === 3) {
const year = parts[0];
const month = parts[1].padStart(2, "0");
const day = parts[2].padStart(2, "0");
rowData.date = `${year}-${month}-${day}`;
} else {
rowData.date = value;
}
}
break;
case "販売店舗":
rowData.shopName = value;
break;
case "商品コード":
rowData.productCode = value;
break;
case "商品名":
rowData.productName = value;
break;
case "客户名":
rowData.customerName = value;
break;
case "分类":
rowData.category = value;
break;
case "販売\n数量":
case "販売数量":
rowData.quantity = parseInt(value) || 0;
break;
case "販売金額\n合計":
case "販売金額合計":
rowData.totalAmount = value.toString();
break;
case "来源":
if (value) rowData.source = value;
break;
case "出库类型":
if (value) rowData.deliveryType = value;
break;
case "目的地":
rowData.destination = value;
break;
case "备注":
rowData.remarks = value;
break;
case "订单号":
rowData.orderNumber = value;
break;
case "品牌":
rowData.brand = value;
break;
default:
fieldConfig.forEach((field) => {
const headerTrim = header.trim().replace(/\n+/g, "");
if (headerTrim.includes(field.displayLabel)) {
specs[field.displayLabel] = value;
}
});
rowData.categorySpecs = JSON.stringify(specs);
break;
}
});

parsedRows.push(rowData);
}

allParsedData.push(...parsedRows);

// 更新文件状态
fileItem.status = "success";
fileItem.parsedCount = parsedRows.length;
} catch (error) {
console.error(`解析文件 ${fileItem.name} 失败:`, error);
fileItem.status = "error";
fileItem.error = error.message;
this.$message.error(
`解析文件 ${fileItem.name} 失败: ${error.message}`
);
}
}

if (allParsedData.length === 0) {
throw new Error("没有可用的数据可以保存");
}

// 调用API保存数据
const response = await saveImportedData(allParsedData);

if (response.code === 200) {
this.saveResult = response.data;
this.$message.success(response.message || "数据保存成功");
this.currentStep = 0;
// 清空文件列表
this.fileList = [];
} else {
this.$message.error(response.message || "数据保存失败");
throw new Error(response.message || "数据保存失败");
}
} catch (error) {
console.error("保存数据失败:", error);
this.$message.error("数据保存失败");
this.$message.error(error.message || "数据保存失败");
} finally {
this.saveLoading = false;
}
},

// 获取状态文本
getStatusText(status) {
const statusMap = {
1: "正常",
2: "客户未匹配",
3: "规格未匹配",
};
return statusMap[status] || "未知";
},

// 跳转到数据列表
goToDataList() {
this.$router.push("/sales-analysis/analysis-data/list");
},

// 重新开始
startOver() {
this.currentStep = 0;
this.selectedDataSource = null;
this.selectedCategoryId = null;
this.selectedCategory = null;
this.selectedShopName = null;
this.processResult = {};
this.saveResult = null;
this.fileList = [];
},
},
};
</script>

Loading…
İptal
Kaydet