|
@@ -60,6 +60,33 @@
|
|
|
</el-col>
|
|
</el-col>
|
|
|
</el-row>
|
|
</el-row>
|
|
|
|
|
|
|
|
|
|
+ <el-row :gutter="20" class="upload-row">
|
|
|
|
|
+ <el-col :span="24">
|
|
|
|
|
+ <el-card class="upload-card">
|
|
|
|
|
+ <template slot="header">
|
|
|
|
|
+ <div class="card-header">
|
|
|
|
|
+ <span>库存数据上传</span>
|
|
|
|
|
+ <div class="card-actions">
|
|
|
|
|
+ <el-button type="primary" size="small" @click="openUploadDialog">
|
|
|
|
|
+ <i class="el-icon-upload2"></i> 上传数据
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ <el-button size="small" @click="refreshData">
|
|
|
|
|
+ <i class="el-icon-refresh"></i> 刷新
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <div class="upload-hint">
|
|
|
|
|
+ 支持上传入库、销售、组装、产品资料、半成品映射文件(xlsx/xls)。上传后将自动刷新本页分析。
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="lastUploadSummary" class="upload-summary">
|
|
|
|
|
+ <el-tag type="success" size="mini">最近上传</el-tag>
|
|
|
|
|
+ <span>{{ lastUploadSummary }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+
|
|
|
<el-row :gutter="20" class="charts-row">
|
|
<el-row :gutter="20" class="charts-row">
|
|
|
<el-col :span="24">
|
|
<el-col :span="24">
|
|
|
<el-card>
|
|
<el-card>
|
|
@@ -187,6 +214,67 @@
|
|
|
</el-card>
|
|
</el-card>
|
|
|
</el-col>
|
|
</el-col>
|
|
|
</el-row>
|
|
</el-row>
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog
|
|
|
|
|
+ title="上传库存分析数据"
|
|
|
|
|
+ :visible.sync="uploadDialogVisible"
|
|
|
|
|
+ width="720px"
|
|
|
|
|
+ @close="resetUploadForm"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ type="info"
|
|
|
|
|
+ show-icon
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ title="至少上传入库数据与销售数据,组装/产品资料/半成品映射可选。"
|
|
|
|
|
+ class="upload-alert"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-form label-width="110px" class="upload-form">
|
|
|
|
|
+ <el-form-item label="入库数据">
|
|
|
|
|
+ <div class="upload-field">
|
|
|
|
|
+ <el-input v-model="uploadNames.purchase" placeholder="未选择文件" readonly />
|
|
|
|
|
+ <el-button size="small" @click="triggerFile('purchase')">选择文件</el-button>
|
|
|
|
|
+ <el-button v-if="uploadFiles.purchase" type="text" size="mini" @click="clearUploadFile('purchase')">清除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="销售数据">
|
|
|
|
|
+ <div class="upload-field">
|
|
|
|
|
+ <el-input v-model="uploadNames.sales" placeholder="未选择文件" readonly />
|
|
|
|
|
+ <el-button size="small" @click="triggerFile('sales')">选择文件</el-button>
|
|
|
|
|
+ <el-button v-if="uploadFiles.sales" type="text" size="mini" @click="clearUploadFile('sales')">清除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="组装数据">
|
|
|
|
|
+ <div class="upload-field">
|
|
|
|
|
+ <el-input v-model="uploadNames.assembly" placeholder="未选择文件" readonly />
|
|
|
|
|
+ <el-button size="small" @click="triggerFile('assembly')">选择文件</el-button>
|
|
|
|
|
+ <el-button v-if="uploadFiles.assembly" type="text" size="mini" @click="clearUploadFile('assembly')">清除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="产品资料">
|
|
|
|
|
+ <div class="upload-field">
|
|
|
|
|
+ <el-input v-model="uploadNames.product" placeholder="未选择文件" readonly />
|
|
|
|
|
+ <el-button size="small" @click="triggerFile('product')">选择文件</el-button>
|
|
|
|
|
+ <el-button v-if="uploadFiles.product" type="text" size="mini" @click="clearUploadFile('product')">清除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="半成品映射">
|
|
|
|
|
+ <div class="upload-field">
|
|
|
|
|
+ <el-input v-model="uploadNames.mapping" placeholder="未选择文件" readonly />
|
|
|
|
|
+ <el-button size="small" @click="triggerFile('mapping')">选择文件</el-button>
|
|
|
|
|
+ <el-button v-if="uploadFiles.mapping" type="text" size="mini" @click="clearUploadFile('mapping')">清除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <input ref="purchaseInput" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange('purchase', $event)">
|
|
|
|
|
+ <input ref="salesInput" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange('sales', $event)">
|
|
|
|
|
+ <input ref="assemblyInput" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange('assembly', $event)">
|
|
|
|
|
+ <input ref="productInput" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange('product', $event)">
|
|
|
|
|
+ <input ref="mappingInput" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange('mapping', $event)">
|
|
|
|
|
+ <span slot="footer" class="dialog-footer">
|
|
|
|
|
+ <el-button @click="uploadDialogVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="uploadLoading" @click="submitUpload">上传并刷新</el-button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
@@ -212,7 +300,24 @@ export default {
|
|
|
spuTableData: [],
|
|
spuTableData: [],
|
|
|
loading: false,
|
|
loading: false,
|
|
|
spuLoading: false,
|
|
spuLoading: false,
|
|
|
- chartInstance: null
|
|
|
|
|
|
|
+ chartInstance: null,
|
|
|
|
|
+ uploadDialogVisible: false,
|
|
|
|
|
+ uploadLoading: false,
|
|
|
|
|
+ uploadFiles: {
|
|
|
|
|
+ purchase: null,
|
|
|
|
|
+ sales: null,
|
|
|
|
|
+ assembly: null,
|
|
|
|
|
+ product: null,
|
|
|
|
|
+ mapping: null
|
|
|
|
|
+ },
|
|
|
|
|
+ uploadNames: {
|
|
|
|
|
+ purchase: '',
|
|
|
|
|
+ sales: '',
|
|
|
|
|
+ assembly: '',
|
|
|
|
|
+ product: '',
|
|
|
|
|
+ mapping: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ lastUploadSummary: ''
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
mounted() {
|
|
mounted() {
|
|
@@ -230,6 +335,9 @@ export default {
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
methods: {
|
|
methods: {
|
|
|
|
|
+ inventoryRequest(config) {
|
|
|
|
|
+ return request({ timeout: 30000, ...config })
|
|
|
|
|
+ },
|
|
|
normalizeResponse(res) {
|
|
normalizeResponse(res) {
|
|
|
if (!res) return null
|
|
if (!res) return null
|
|
|
if (res.code === 200) return res.data
|
|
if (res.code === 200) return res.data
|
|
@@ -279,7 +387,7 @@ export default {
|
|
|
},
|
|
},
|
|
|
async fetchOverviewData() {
|
|
async fetchOverviewData() {
|
|
|
try {
|
|
try {
|
|
|
- const res = await request({ url: '/api/inventory/overview', method: 'get' })
|
|
|
|
|
|
|
+ const res = await this.inventoryRequest({ url: '/api/inventory/overview', method: 'get' })
|
|
|
const data = this.normalizeResponse(res)
|
|
const data = this.normalizeResponse(res)
|
|
|
if (data) this.overviewData = { ...this.overviewData, ...data }
|
|
if (data) this.overviewData = { ...this.overviewData, ...data }
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -289,7 +397,7 @@ export default {
|
|
|
async fetchMonthlyComparison() {
|
|
async fetchMonthlyComparison() {
|
|
|
this.monthlyLoading = true
|
|
this.monthlyLoading = true
|
|
|
try {
|
|
try {
|
|
|
- const res = await request({ url: '/api/inventory/monthly-comparison', method: 'get' })
|
|
|
|
|
|
|
+ const res = await this.inventoryRequest({ url: '/api/inventory/monthly-comparison', method: 'get' })
|
|
|
const data = this.normalizeResponse(res)
|
|
const data = this.normalizeResponse(res)
|
|
|
if (data && Array.isArray(data.months)) {
|
|
if (data && Array.isArray(data.months)) {
|
|
|
this.monthlyComparison = data.months.map((month, idx) => ({
|
|
this.monthlyComparison = data.months.map((month, idx) => ({
|
|
@@ -313,7 +421,7 @@ export default {
|
|
|
async fetchSkuSummary() {
|
|
async fetchSkuSummary() {
|
|
|
this.loading = true
|
|
this.loading = true
|
|
|
try {
|
|
try {
|
|
|
- const res = await request({ url: '/api/inventory/sku-summary', method: 'get' })
|
|
|
|
|
|
|
+ const res = await this.inventoryRequest({ url: '/api/inventory/sku-summary', method: 'get' })
|
|
|
const data = this.normalizeResponse(res)
|
|
const data = this.normalizeResponse(res)
|
|
|
if (Array.isArray(data)) this.skuTableData = data
|
|
if (Array.isArray(data)) this.skuTableData = data
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -325,7 +433,7 @@ export default {
|
|
|
async fetchSpuSummary() {
|
|
async fetchSpuSummary() {
|
|
|
this.spuLoading = true
|
|
this.spuLoading = true
|
|
|
try {
|
|
try {
|
|
|
- const res = await request({ url: '/api/inventory/spu-summary', method: 'get' })
|
|
|
|
|
|
|
+ const res = await this.inventoryRequest({ url: '/api/inventory/spu-summary', method: 'get' })
|
|
|
const data = this.normalizeResponse(res)
|
|
const data = this.normalizeResponse(res)
|
|
|
if (Array.isArray(data)) this.spuTableData = data
|
|
if (Array.isArray(data)) this.spuTableData = data
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -340,6 +448,92 @@ export default {
|
|
|
this.fetchSkuSummary()
|
|
this.fetchSkuSummary()
|
|
|
this.fetchSpuSummary()
|
|
this.fetchSpuSummary()
|
|
|
},
|
|
},
|
|
|
|
|
+ openUploadDialog() {
|
|
|
|
|
+ this.uploadDialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ triggerFile(type) {
|
|
|
|
|
+ const refKey = `${type}Input`
|
|
|
|
|
+ const input = this.$refs[refKey]
|
|
|
|
|
+ if (input && input.click) {
|
|
|
|
|
+ input.click()
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ handleFileChange(type, event) {
|
|
|
|
|
+ const file = event && event.target ? event.target.files[0] : null
|
|
|
|
|
+ if (!file) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const name = (file.name || '').toLowerCase()
|
|
|
|
|
+ if (!name.endsWith('.xlsx') && !name.endsWith('.xls')) {
|
|
|
|
|
+ this.$message.error('仅支持上传 xlsx/xls 文件')
|
|
|
|
|
+ if (event && event.target) event.target.value = ''
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.uploadFiles[type] = file
|
|
|
|
|
+ this.uploadNames[type] = file.name
|
|
|
|
|
+ if (event && event.target) event.target.value = ''
|
|
|
|
|
+ },
|
|
|
|
|
+ clearUploadFile(type) {
|
|
|
|
|
+ this.uploadFiles[type] = null
|
|
|
|
|
+ this.uploadNames[type] = ''
|
|
|
|
|
+ },
|
|
|
|
|
+ resetUploadForm() {
|
|
|
|
|
+ this.uploadFiles = {
|
|
|
|
|
+ purchase: null,
|
|
|
|
|
+ sales: null,
|
|
|
|
|
+ assembly: null,
|
|
|
|
|
+ product: null,
|
|
|
|
|
+ mapping: null
|
|
|
|
|
+ }
|
|
|
|
|
+ this.uploadNames = {
|
|
|
|
|
+ purchase: '',
|
|
|
|
|
+ sales: '',
|
|
|
|
|
+ assembly: '',
|
|
|
|
|
+ product: '',
|
|
|
|
|
+ mapping: ''
|
|
|
|
|
+ }
|
|
|
|
|
+ this.uploadLoading = false
|
|
|
|
|
+ },
|
|
|
|
|
+ hasUploadFiles() {
|
|
|
|
|
+ return ['purchase', 'sales', 'assembly', 'product', 'mapping'].some(key => this.uploadFiles[key])
|
|
|
|
|
+ },
|
|
|
|
|
+ async submitUpload() {
|
|
|
|
|
+ if (!this.hasUploadFiles()) {
|
|
|
|
|
+ this.$message.error('请至少选择一个要上传的文件')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const formData = new FormData()
|
|
|
|
|
+ if (this.uploadFiles.purchase) formData.append('purchaseFile', this.uploadFiles.purchase)
|
|
|
|
|
+ if (this.uploadFiles.sales) formData.append('salesFile', this.uploadFiles.sales)
|
|
|
|
|
+ if (this.uploadFiles.assembly) formData.append('assemblyFile', this.uploadFiles.assembly)
|
|
|
|
|
+ if (this.uploadFiles.product) formData.append('productFile', this.uploadFiles.product)
|
|
|
|
|
+ if (this.uploadFiles.mapping) formData.append('semiMappingFile', this.uploadFiles.mapping)
|
|
|
|
|
+
|
|
|
|
|
+ this.uploadLoading = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await this.inventoryRequest({
|
|
|
|
|
+ url: '/api/inventory/upload',
|
|
|
|
|
+ method: 'post',
|
|
|
|
|
+ data: formData,
|
|
|
|
|
+ headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res && res.code === 200) {
|
|
|
|
|
+ const count = res.data && res.data.count ? res.data.count : 0
|
|
|
|
|
+ this.lastUploadSummary = `已保存 ${count} 个文件`
|
|
|
|
|
+ this.$message.success('上传成功,已刷新分析数据')
|
|
|
|
|
+ this.uploadDialogVisible = false
|
|
|
|
|
+ this.resetUploadForm()
|
|
|
|
|
+ await this.refreshData()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.$message.error((res && res.msg) || '上传失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('上传库存数据失败:', error)
|
|
|
|
|
+ this.$message.error('上传失败,请检查文件格式或后端日志')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.uploadLoading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
getTurnoverRateType(rate) {
|
|
getTurnoverRateType(rate) {
|
|
|
if (rate === 0) return 'info'
|
|
if (rate === 0) return 'info'
|
|
|
if (rate >= 1) return 'success'
|
|
if (rate >= 1) return 'success'
|
|
@@ -418,6 +612,47 @@ export default {
|
|
|
margin-bottom: 20px;
|
|
margin-bottom: 20px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.upload-row {
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-card .card-actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-hint {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-summary {
|
|
|
|
|
+ margin-top: 10px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-form {
|
|
|
|
|
+ margin-top: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-field {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.hidden-file-input {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-alert {
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.card-header {
|
|
.card-header {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
justify-content: space-between;
|