Prechádzať zdrojové kódy

Merge branch 'master' of https://gogs.dev.dazesoft.cn/dtm_organization/dtm_vue

Gogs 2 mesiacov pred
rodič
commit
8d30fafef0

+ 3 - 2
src/utils/request.js

@@ -1,4 +1,4 @@
-import axios from 'axios'
+import axios from 'axios'
 import { Notification, MessageBox, Message, Loading } from 'element-ui'
 import store from '@/store'
 import { getToken } from '@/utils/auth'
@@ -15,7 +15,7 @@ axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
 // 创建axios实例
 const service = axios.create({
   // axios中请求配置有baseURL选项,表示请求URL公共部分
-  baseURL: process.env.VUE_APP_BASE_API,
+  baseURL: process.env.VUE_APP_BASE_API || '/dev-api',
   // 超时
   timeout: 10000
 })
@@ -150,3 +150,4 @@ export function download(url, params, filename, config) {
 }
 
 export default service
+

+ 132 - 132
src/views/storage/accuracy/index.vue

@@ -121,142 +121,142 @@
       </el-table>
     </el-card>
 
-    <el-card class="table-card">
-      <el-table :data="semiProductList" stripe style="width: 100%">
-        <el-table-column type="selection" width="55" />
-        <el-table-column prop="code" label="编码" width="120" fixed />
-        <el-table-column prop="name" label="半成品名称" width="200" />
-        <el-table-column label="类别" width="120">
-          <template slot-scope="scope">
-            <el-tag>{{ getCategoryText(scope.row.category) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="quantity" label="库存数量" width="100" sortable />
-        <el-table-column prop="safeStock" label="安全库存" width="100" />
-        <el-table-column label="状态" width="100">
-          <template slot-scope="scope">
-            <el-tag :type="getStatusType(scope.row.status)">
-              {{ getStatusText(scope.row.status) }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="可用于组装" width="150">
-          <template slot-scope="scope">
-            <el-popover placement="top" :width="300" trigger="hover">
-              <template slot="reference">
-                <el-tag type="info" style="cursor: pointer;">
-                  {{ scope.row.usedForProducts.length }} 种成品
-                </el-tag>
-              </template>
-              <div>
-                <p style="font-weight: bold; margin-bottom: 8px;">可组装成品</p>
-                <el-tag
-                  v-for="product in scope.row.usedForProducts"
-                  :key="product"
-                  size="small"
-                  style="margin: 2px 4px;"
-                >
-                  {{ product }}
-                </el-tag>
-              </div>
-            </el-popover>
-          </template>
-        </el-table-column>
-        <el-table-column prop="supplier" label="供应商" width="150" />
-        <el-table-column prop="leadTime" label="采购周期" width="100">
-          <template slot-scope="scope">
-            {{ scope.row.leadTime }} 天
-          </template>
-        </el-table-column>
-        <el-table-column prop="unitPrice" label="单价(元)" width="100" />
-        <el-table-column prop="totalValue" label="库存价值(元)" width="120" sortable />
-        <el-table-column label="操作" width="240" fixed="right">
-          <template slot-scope="scope">
-            <el-button type="primary" size="small" @click="handleDetail(scope.row)">详情</el-button>
-            <el-button type="success" size="small" @click="handleEdit(scope.row)">编辑</el-button>
-            <el-button type="warning" size="small" @click="handleAssemble(scope.row)">组装</el-button>
-            <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
+<!--    <el-card class="table-card">-->
+<!--      <el-table :data="semiProductList" stripe style="width: 100%">-->
+<!--        <el-table-column type="selection" width="55" />-->
+<!--        <el-table-column prop="code" label="编码" width="120" fixed />-->
+<!--        <el-table-column prop="name" label="半成品名称" width="200" />-->
+<!--        <el-table-column label="类别" width="120">-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-tag>{{ getCategoryText(scope.row.category) }}</el-tag>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="quantity" label="库存数量" width="100" sortable />-->
+<!--        <el-table-column prop="safeStock" label="安全库存" width="100" />-->
+<!--        <el-table-column label="状态" width="100">-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-tag :type="getStatusType(scope.row.status)">-->
+<!--              {{ getStatusText(scope.row.status) }}-->
+<!--            </el-tag>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column label="可用于组装" width="150">-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-popover placement="top" :width="300" trigger="hover">-->
+<!--              <template slot="reference">-->
+<!--                <el-tag type="info" style="cursor: pointer;">-->
+<!--                  {{ scope.row.usedForProducts.length }} 种成品-->
+<!--                </el-tag>-->
+<!--              </template>-->
+<!--              <div>-->
+<!--                <p style="font-weight: bold; margin-bottom: 8px;">可组装成品</p>-->
+<!--                <el-tag-->
+<!--                  v-for="product in scope.row.usedForProducts"-->
+<!--                  :key="product"-->
+<!--                  size="small"-->
+<!--                  style="margin: 2px 4px;"-->
+<!--                >-->
+<!--                  {{ product }}-->
+<!--                </el-tag>-->
+<!--              </div>-->
+<!--            </el-popover>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="supplier" label="供应商" width="150" />-->
+<!--        <el-table-column prop="leadTime" label="采购周期" width="100">-->
+<!--          <template slot-scope="scope">-->
+<!--            {{ scope.row.leadTime }} 天-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="unitPrice" label="单价(元)" width="100" />-->
+<!--        <el-table-column prop="totalValue" label="库存价值(元)" width="120" sortable />-->
+<!--        <el-table-column label="操作" width="240" fixed="right">-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-button type="primary" size="small" @click="handleDetail(scope.row)">详情</el-button>-->
+<!--            <el-button type="success" size="small" @click="handleEdit(scope.row)">编辑</el-button>-->
+<!--            <el-button type="warning" size="small" @click="handleAssemble(scope.row)">组装</el-button>-->
+<!--            <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--      </el-table>-->
 
-      <el-pagination
-        :current-page="currentPage"
-        :page-size="pageSize"
-        :total="totalItems"
-        :page-sizes="[10, 20, 50, 100]"
-        layout="total, sizes, prev, pager, next, jumper"
-        style="margin-top: 20px; justify-content: flex-end;"
-      />
-    </el-card>
+<!--      <el-pagination-->
+<!--        :current-page="currentPage"-->
+<!--        :page-size="pageSize"-->
+<!--        :total="totalItems"-->
+<!--        :page-sizes="[10, 20, 50, 100]"-->
+<!--        layout="total, sizes, prev, pager, next, jumper"-->
+<!--        style="margin-top: 20px; justify-content: flex-end;"-->
+<!--      />-->
+<!--    </el-card>-->
 
-    <el-row :gutter="20" class="charts-row">
-      <el-col :span="12">
-        <el-card>
-          <template slot="header">
-            <span>半成品库存分布</span>
-          </template>
-          <div ref="stockDistributionChart" class="chart" />
-        </el-card>
-      </el-col>
-      <el-col :span="12">
-        <el-card>
-          <template slot="header">
-            <span>半成品使用趋势</span>
-          </template>
-          <div ref="usageTrendChart" class="chart" />
-        </el-card>
-      </el-col>
-    </el-row>
+<!--    <el-row :gutter="20" class="charts-row">-->
+<!--      <el-col :span="12">-->
+<!--        <el-card>-->
+<!--          <template slot="header">-->
+<!--            <span>半成品库存分布</span>-->
+<!--          </template>-->
+<!--          <div ref="stockDistributionChart" class="chart" />-->
+<!--        </el-card>-->
+<!--      </el-col>-->
+<!--      <el-col :span="12">-->
+<!--        <el-card>-->
+<!--          <template slot="header">-->
+<!--            <span>半成品使用趋势</span>-->
+<!--          </template>-->
+<!--          <div ref="usageTrendChart" class="chart" />-->
+<!--        </el-card>-->
+<!--      </el-col>-->
+<!--    </el-row>-->
 
-    <el-card class="bom-card">
-      <template slot="header">
-        <div class="card-header">
-          <span>半成品BOM关系图</span>
-          <el-select
-            v-model="selectedBomProduct"
-            placeholder="选择成品查看BOM"
-            style="width: 250px;"
-          >
-            <el-option label="智能手表 Pro X1" value="watch" />
-            <el-option label="无线耳机 Elite" value="earphone" />
-            <el-option label="智能音箱" value="speaker" />
-          </el-select>
-        </div>
-      </template>
-      <div ref="bomRelationChart" class="chart" style="height: 400px;"></div>
-    </el-card>
+<!--    <el-card class="bom-card">-->
+<!--      <template slot="header">-->
+<!--        <div class="card-header">-->
+<!--          <span>半成品BOM关系图</span>-->
+<!--          <el-select-->
+<!--            v-model="selectedBomProduct"-->
+<!--            placeholder="选择成品查看BOM"-->
+<!--            style="width: 250px;"-->
+<!--          >-->
+<!--            <el-option label="智能手表 Pro X1" value="watch" />-->
+<!--            <el-option label="无线耳机 Elite" value="earphone" />-->
+<!--            <el-option label="智能音箱" value="speaker" />-->
+<!--          </el-select>-->
+<!--        </div>-->
+<!--      </template>-->
+<!--      <div ref="bomRelationChart" class="chart" style="height: 400px;"></div>-->
+<!--    </el-card>-->
 
-    <el-card class="turnover-card">
-      <template slot="header">
-        <span>半成品周转效率分析</span>
-      </template>
-      <el-table :data="turnoverAnalysis" stripe>
-        <el-table-column prop="name" label="半成品名称" width="200" />
-        <el-table-column prop="avgTurnover" label="平均周转天数" width="150" sortable>
-          <template slot-scope="scope">
-            <span :style="{ color: getTurnoverColor(scope.row.avgTurnover) }">
-              {{ scope.row.avgTurnover }} 天
-            </span>
-          </template>
-        </el-table-column>
-        <el-table-column prop="usageRate" label="使用率" width="120" sortable>
-          <template slot-scope="scope">
-            <el-progress :percentage="scope.row.usageRate" :color="getProgressColor(scope.row.usageRate)" />
-          </template>
-        </el-table-column>
-        <el-table-column prop="monthlyConsumption" label="月消耗量" width="120" />
-        <el-table-column prop="reorderPoint" label="再订货点" width="120" />
-        <el-table-column label="库存状态" width="120">
-          <template slot-scope="scope">
-            <el-tag :type="getStockStatusType(scope.row.stockStatus)">
-              {{ scope.row.stockStatus }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="suggestion" label="优化建议" min-width="250" />
-      </el-table>
-    </el-card>
+<!--    <el-card class="turnover-card">-->
+<!--      <template slot="header">-->
+<!--        <span>半成品周转效率分析</span>-->
+<!--      </template>-->
+<!--      <el-table :data="turnoverAnalysis" stripe>-->
+<!--        <el-table-column prop="name" label="半成品名称" width="200" />-->
+<!--        <el-table-column prop="avgTurnover" label="平均周转天数" width="150" sortable>-->
+<!--          <template slot-scope="scope">-->
+<!--            <span :style="{ color: getTurnoverColor(scope.row.avgTurnover) }">-->
+<!--              {{ scope.row.avgTurnover }} 天-->
+<!--            </span>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="usageRate" label="使用率" width="120" sortable>-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-progress :percentage="scope.row.usageRate" :color="getProgressColor(scope.row.usageRate)" />-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="monthlyConsumption" label="月消耗量" width="120" />-->
+<!--        <el-table-column prop="reorderPoint" label="再订货点" width="120" />-->
+<!--        <el-table-column label="库存状态" width="120">-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-tag :type="getStockStatusType(scope.row.stockStatus)">-->
+<!--              {{ scope.row.stockStatus }}-->
+<!--            </el-tag>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column prop="suggestion" label="优化建议" min-width="250" />-->
+<!--      </el-table>-->
+<!--    </el-card>-->
   </div>
 </template>
 

+ 366 - 0
src/views/storage/agent/index.vue

@@ -0,0 +1,366 @@
+<template>
+  <div id="app">
+    <!-- 顶部标题栏,新增刷新按钮 -->
+    <div class="chat-header">
+      <div class="header-title">
+        <span class="bot-icon">AI</span>
+        <span>智能助手</span>
+      </div>
+      <button class="refresh-btn" @click="refreshChat" :disabled="loading">
+        <span class="refresh-icon">↻</span>
+      </button>
+    </div>
+
+    <div class="chat-container">
+      <div class="chatbox" ref="chatbox">
+        <div class="message" v-for="(msg, index) in chatMessages" :key="index">
+          <!-- 用户消息(右对齐) -->
+          <div class="user-message" v-if="msg.type === 'user'">
+            <div class="text">{{ msg.text }}</div>
+            <div class="avatar user-avatar">你</div>
+          </div>
+          <!-- 机器人消息(左对齐) -->
+          <div class="bot-message" v-if="msg.type === 'bot'">
+            <div class="avatar bot-avatar">AI</div>
+            <div class="text">
+              <span v-if="msg.loading">思考中...</span>
+              <span v-else>{{ msg.text }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="input-group">
+        <input
+          type="text"
+          v-model="userInput"
+          @keyup.enter="sendMessage"
+          :disabled="loading"
+          placeholder="请输入问题,例如:SKU PRD-001 库存风险如何?"
+        />
+        <button @click="sendMessage" :disabled="loading || !userInput.trim()">发送</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+const DEFAULT_MESSAGES = [
+  {
+    type: 'bot',
+    text: '你好!我是你的智能助手,有什么可以帮到你的吗?'
+  },
+  {
+    type: 'bot',
+    text: '你可以输入产品SKU来查询对应单品的库存、销量和周转情况。'
+  }
+]
+
+export default {
+  data() {
+    return {
+      userInput: '',
+      loading: false,
+      chatMessages: DEFAULT_MESSAGES.map(item => ({ ...item }))
+    }
+  },
+  methods: {
+    async sendMessage() {
+      const content = this.userInput.trim()
+      if (!content || this.loading) return
+
+      const history = this.buildHistory()
+      const sku = this.extractSku(content)
+
+      this.chatMessages.push({
+        type: 'user',
+        text: content
+      })
+
+      const botMessage = {
+        type: 'bot',
+        text: '',
+        loading: true
+      }
+      this.chatMessages.push(botMessage)
+
+      this.userInput = ''
+      this.loading = true
+      this.scrollToBottom()
+
+      try {
+        const res = await request({
+          url: '/api/agent/chat',
+          method: 'post',
+          timeout: 35000,
+          data: {
+            message: content,
+            history,
+            sku
+          }
+        })
+        const reply = res && res.data && res.data.reply ? res.data.reply : '未收到有效回复,请稍后再试。'
+        botMessage.text = reply
+      } catch (e) {
+        botMessage.text = '调用后端失败,请稍后再试。'
+      } finally {
+        botMessage.loading = false
+        this.loading = false
+        this.scrollToBottom()
+      }
+    },
+
+    buildHistory() {
+      return this.chatMessages
+        .filter(item => item && !item.loading && item.text)
+        .slice(-6)
+        .map(item => ({
+          role: item.type === 'user' ? 'user' : 'assistant',
+          content: item.text
+        }))
+    },
+
+    extractSku(text) {
+      if (!text) return ''
+      const match = text.match(/(?:sku|SKU)[::]?\s*([A-Za-z0-9_-]{3,})/)
+      return match && match[1] ? match[1] : ''
+    },
+
+    refreshChat() {
+      this.chatMessages = DEFAULT_MESSAGES.map(item => ({ ...item }))
+      this.userInput = ''
+      this.loading = false
+      this.$nextTick(() => {
+        const chatbox = this.$refs.chatbox
+        if (chatbox) {
+          chatbox.scrollTop = 0
+        }
+      })
+    },
+
+    scrollToBottom() {
+      this.$nextTick(() => {
+        const chatbox = this.$refs.chatbox
+        if (chatbox) {
+          chatbox.scrollTop = chatbox.scrollHeight
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+#app {
+  font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
+  background-color: #f7f8fa;
+  color: #333;
+  margin: 0;
+  padding: 20px;
+  max-width: 900px;
+  margin: 0 auto;
+}
+
+/* 顶部标题栏 */
+.chat-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 20px;
+  background: #fff;
+  border-radius: 8px 8px 0 0;
+  border-bottom: 1px solid #e5e7eb;
+  margin-bottom: 0;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.bot-icon {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+}
+
+.refresh-btn {
+  background: transparent;
+  border: none;
+  font-size: 18px;
+  cursor: pointer;
+  color: #909399;
+  padding: 4px;
+  border-radius: 4px;
+  transition: all 0.3s;
+}
+
+.refresh-btn:hover {
+  background: #f5f7fa;
+  color: #409eff;
+}
+
+.refresh-btn:disabled {
+  cursor: not-allowed;
+  color: #c0c4cc;
+  background: transparent;
+}
+
+/* 对话容器 */
+.chat-container {
+  background: #ffffff;
+  border-radius: 0 0 8px 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+}
+
+.chatbox {
+  height: 450px;
+  overflow-y: auto;
+  padding: 20px;
+  background: #fafbfc;
+}
+
+/* 滚动条美化 */
+.chatbox::-webkit-scrollbar {
+  width: 6px;
+}
+
+.chatbox::-webkit-scrollbar-thumb {
+  background: #dcdfe6;
+  border-radius: 3px;
+}
+
+.chatbox::-webkit-scrollbar-track {
+  background: #f5f7fa;
+}
+
+.message {
+  display: flex;
+  margin: 16px 0;
+  align-items: flex-start;
+}
+
+/* 用户消息样式 */
+.user-message {
+  margin-left: auto;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+}
+
+.user-message .text {
+  background: #409eff;
+  color: #fff;
+  padding: 10px 14px;
+  border-radius: 14px 14px 0 14px;
+  max-width: 70%;
+  font-size: 14px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+}
+
+.user-avatar {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #67c23a;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+}
+
+/* 机器人消息样式 */
+.bot-message {
+  margin-right: auto;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+}
+
+.bot-message .text {
+  background: #fff;
+  color: #303133;
+  padding: 10px 14px;
+  border-radius: 14px 14px 14px 0;
+  max-width: 70%;
+  font-size: 14px;
+  line-height: 1.5;
+  border: 1px solid #ebeef5;
+  white-space: pre-wrap;
+}
+
+.bot-avatar {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+}
+
+/* 输入区域 */
+.input-group {
+  display: flex;
+  padding: 16px 20px;
+  border-top: 1px solid #e5e7eb;
+  background: #fff;
+}
+
+input[type="text"] {
+  flex: 1;
+  padding: 10px 14px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  font-size: 14px;
+  margin-right: 12px;
+  outline: none;
+  transition: border-color 0.3s;
+}
+
+input[type="text"]:focus {
+  border-color: #409eff;
+}
+
+input[type="text"]:disabled {
+  background: #f5f7fa;
+  color: #c0c4cc;
+}
+
+button {
+  padding: 0 18px;
+  border: none;
+  background-color: #409eff;
+  color: white;
+  border-radius: 4px;
+  font-size: 14px;
+  cursor: pointer;
+  transition: background-color 0.3s ease;
+}
+
+button:hover {
+  background-color: #337ecc;
+}
+
+button:disabled {
+  cursor: not-allowed;
+  background-color: #a0cfff;
+}
+</style>

+ 240 - 5
src/views/storage/overview/index.vue

@@ -60,6 +60,33 @@
       </el-col>
     </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-col :span="24">
         <el-card>
@@ -187,6 +214,67 @@
         </el-card>
       </el-col>
     </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>
 </template>
 
@@ -212,7 +300,24 @@ export default {
       spuTableData: [],
       loading: 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() {
@@ -230,6 +335,9 @@ export default {
     }
   },
   methods: {
+    inventoryRequest(config) {
+      return request({ timeout: 30000, ...config })
+    },
     normalizeResponse(res) {
       if (!res) return null
       if (res.code === 200) return res.data
@@ -279,7 +387,7 @@ export default {
     },
     async fetchOverviewData() {
       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)
         if (data) this.overviewData = { ...this.overviewData, ...data }
       } catch (error) {
@@ -289,7 +397,7 @@ export default {
     async fetchMonthlyComparison() {
       this.monthlyLoading = true
       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)
         if (data && Array.isArray(data.months)) {
           this.monthlyComparison = data.months.map((month, idx) => ({
@@ -313,7 +421,7 @@ export default {
     async fetchSkuSummary() {
       this.loading = true
       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)
         if (Array.isArray(data)) this.skuTableData = data
       } catch (error) {
@@ -325,7 +433,7 @@ export default {
     async fetchSpuSummary() {
       this.spuLoading = true
       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)
         if (Array.isArray(data)) this.spuTableData = data
       } catch (error) {
@@ -340,6 +448,92 @@ export default {
       this.fetchSkuSummary()
       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) {
       if (rate === 0) return 'info'
       if (rate >= 1) return 'success'
@@ -418,6 +612,47 @@ export default {
   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 {
   display: flex;
   justify-content: space-between;

+ 4 - 0
vue.config.js

@@ -51,6 +51,10 @@ module.exports = {
         changeOrigin: true,
         pathRewrite: { ['^' + process.env.VUE_APP_BASE_API]: '' }
       },
+      '^/api': {
+        target: baseUrl,
+        changeOrigin: true
+      },
       // springdoc proxy
       '^/v3/api-docs/(.*)': {
         target: baseUrl,