Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

徐滕 3 gün önce
ebeveyn
işleme
a1d6ba1d9e

+ 20 - 4
src/api/program/MaterialPriceService.js

@@ -35,12 +35,28 @@ export default class MaterialPriceService {
     })
   }
 
-  // 管理员手动采集;date 为空表示取各站点最新数据
-  sync (date) {
+  // 提交历史区间采集;接口只入队,真正的抓取在 Java 后台执行
+  syncRange (startDate, endDate) {
     return request({
-      url: prefix + '/materialPrice/record/sync',
+      url: prefix + '/materialPrice/record/syncRange',
       method: 'post',
-      params: date ? { date: date } : {}
+      params: { startDate: startDate, endDate: endDate }
+    })
+  }
+
+  // 最近一次区间采集的状态与进度(前端轮询用)
+  syncStatus () {
+    return request({
+      url: prefix + '/materialPrice/record/syncStatus',
+      method: 'get'
+    })
+  }
+
+  // 当前是否有区间采集在执行(页面重新激活时判断要不要继续轮询)
+  syncRunning () {
+    return request({
+      url: prefix + '/materialPrice/record/syncRunning',
+      method: 'get'
     })
   }
 }

+ 319 - 39
src/views/program/materialPrice/MaterialPriceList.vue

@@ -1,25 +1,28 @@
 <template>
   <div class="page">
-    <!-- 查询条件:站点 -> 分类 -> 省份 -> 城市 级联;物料/规格/含税按需求不提供筛选 -->
     <el-form :inline="true" class="query-form" ref="searchForm" :model="searchForm" @keyup.enter.native="refreshList()" @submit.native.prevent>
-      <el-form-item label="站点" prop="siteCode">
-        <el-select v-model="searchForm.siteCode" placeholder="全部" clearable style="width: 150px" @change="handleSiteChange">
-          <el-option v-for="item in options.sites" :key="item.siteCode" :label="item.siteName" :value="item.siteCode"></el-option>
-        </el-select>
+<!--      <el-form-item label="站点" prop="siteCode">-->
+<!--        <el-select v-model="searchForm.siteCode" placeholder="全部" clearable style="width: 150px" @change="handleSiteChange">-->
+<!--          <el-option v-for="item in options.sites" :key="item.siteCode" :label="item.siteName" :value="item.siteCode"></el-option>-->
+<!--        </el-select>-->
+<!--      </el-form-item>-->
+      <el-form-item label="物料" prop="materialName">
+        <el-input v-model="searchForm.materialName" placeholder="输入关键字模糊查询" clearable style="width: 180px"></el-input>
       </el-form-item>
       <el-form-item label="分类" prop="category">
-        <el-select v-model="searchForm.category" placeholder="全部" clearable style="width: 140px" @change="handleCategoryChange">
+        <!-- filterable:输入文字即可从选项里过滤,不输入时仍按下拉选择 -->
+        <el-select v-model="searchForm.category" placeholder="全部" clearable filterable style="width: 140px" @change="handleCategoryChange">
           <el-option v-for="item in options.categories" :key="item" :label="item" :value="item"></el-option>
         </el-select>
       </el-form-item>
       <el-form-item label="省份" prop="province">
-        <el-select v-model="searchForm.province" placeholder="全部" clearable style="width: 120px" @change="handleProvinceChange">
+        <el-select v-model="searchForm.province" placeholder="全部" clearable filterable style="width: 120px" @change="handleProvinceChange">
           <el-option v-for="item in options.provinces" :key="item" :label="item" :value="item"></el-option>
         </el-select>
       </el-form-item>
       <!-- 城市按所选省份级联;站点没有城市信息时该下拉为空 -->
       <el-form-item label="城市" prop="city">
-        <el-select v-model="searchForm.city" placeholder="全部" clearable style="width: 120px">
+        <el-select v-model="searchForm.city" placeholder="全部" clearable filterable style="width: 120px">
           <el-option v-for="item in options.cities" :key="item" :label="item" :value="item"></el-option>
         </el-select>
       </el-form-item>
@@ -42,16 +45,13 @@
     </el-form>
 
     <div class="jp-table">
-      <!-- custom:列设置;refresh:右上角刷新按钮 -->
       <vxe-toolbar :refresh="{query: refreshList}" custom>
         <template #buttons>
-          <!-- 立即采集只对管理员角色显示(后端 @PreAuthorize 是最终防线) -->
+          <!-- 只用文字、不加图标:保证按钮内文字水平居中 -->
           <el-button
             v-if="hasPermission('program:materialPrice:sync')"
             type="primary"
-            icon="el-icon-refresh"
-            :loading="syncing"
-            @click="syncNow()">立即采集</el-button>
+            @click="openRangeDialog()">采集历史数据</el-button>
         </template>
       </vxe-toolbar>
       <div class="jp-table-body">
@@ -69,7 +69,6 @@
           :menu-config="{}"
           :data="dataList">
           <vxe-column type="seq" width="60" title="序号"></vxe-column>
-          <vxe-column title="站点" field="siteName" width="130" align="left"></vxe-column>
           <vxe-column title="分类" field="category" width="100" align="left"></vxe-column>
           <!-- 省份所有站点都有;城市只有部分站点提供,缺失显示「—」 -->
           <vxe-column title="省份" field="province" width="100" align="left"></vxe-column>
@@ -80,7 +79,6 @@
           <vxe-column title="规格" width="110" align="left">
             <template #default="scope">{{ scope.row.spec || '—' }}</template>
           </vxe-column>
-          <!-- 三类价格按站点不同:废废网只有价格,中果/回收商网只有最低/最高价,缺失显示「—」 -->
           <vxe-column title="价格" width="100" align="right">
             <template #default="scope">{{ formatPrice(scope.row.price) }}</template>
           </vxe-column>
@@ -99,7 +97,6 @@
               <el-tag v-else size="small" type="info">未声明</el-tag>
             </template>
           </vxe-column>
-          <!-- 备注、采集时间仅后端返回,页面不再展示 -->
           <vxe-column title="数据日期" field="priceDate" width="110" align="center"></vxe-column>
           <vxe-column title="来源" width="70" align="center">
             <template #default="scope">
@@ -107,6 +104,7 @@
               <span v-else>—</span>
             </template>
           </vxe-column>
+          <vxe-column title="站点" field="siteName" width="130" align="left"></vxe-column>
           <vxe-column title="操作" width="90" fixed="right" align="center">
             <template #default="scope">
               <el-button text type="primary" @click="showTrend(scope.row)">价格走势</el-button>
@@ -125,6 +123,65 @@
         </vxe-pager>
       </div>
     </div>
+
+    <!-- 历史区间采集:提交后后台执行,这里每 3 秒轮询一次进度;关闭弹窗不会中断采集 -->
+    <el-dialog
+      title="历史数据采集"
+      v-model="rangeDialogVisible"
+      width="520px"
+      :close-on-click-modal="false">
+      <!-- 采集中:进度条 + 已入库/跳过条数 -->
+      <div v-if="rangeRunning" class="range-running">
+        <el-progress :percentage="rangePercent" :stroke-width="14"></el-progress>
+        <div class="range-line">
+          已完成 {{ rangeDone }} / {{ rangeTotal || '?' }} 个分片
+        </div>
+        <div class="range-line">当前:{{ rangeCurrentStage || '准备中' }}</div>
+        <div class="range-line">
+          已入库 {{ rangeLog.savedCount || 0 }} 条,跳过 {{ rangeLog.failedCount || 0 }} 条
+        </div>
+        <div class="range-tip">采集在后台执行,关闭弹窗不会中断;可以稍后重新打开查看进度。</div>
+      </div>
+      <!-- 未在采集:选择区间后提交 -->
+      <div v-else class="range-form">
+        <el-form label-width="80px">
+          <el-form-item label="开始日期">
+            <el-date-picker
+              v-model="rangeStartDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择开始日期"
+              :disabled-date="disabledStartDate"
+              style="width: 220px">
+            </el-date-picker>
+          </el-form-item>
+          <el-form-item label="结束日期">
+            <el-date-picker
+              v-model="rangeEndDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择结束日期"
+              :disabled-date="disabledEndDate"
+              style="width: 220px">
+            </el-date-picker>
+          </el-form-item>
+        </el-form>
+        <div class="range-tip">
+          单次采集的时间总跨度不能超过 {{ maxRangeDays }} 天;结束日期最晚为昨天。区间采集只补历史数据;每天的增量数据由定时任务自动采集。
+        </div>
+        <div v-if="lastRangeSummary" class="range-tip">{{ lastRangeSummary }}</div>
+      </div>
+      <template #footer>
+        <el-button @click="rangeDialogVisible = false">关闭</el-button>
+        <el-button
+          v-if="!rangeRunning"
+          type="primary"
+          :loading="rangeSubmitting"
+          :disabled="!rangeStartDate || !rangeEndDate"
+          @click="submitRange()">开始采集</el-button>
+      </template>
+    </el-dialog>
+
     <MaterialPriceTrendDialog ref="trendDialog"></MaterialPriceTrendDialog>
   </div>
 </template>
@@ -139,6 +196,7 @@
         // 查询条件;priceDates 为空数组时后端不会按日期过滤
         searchForm: {
           siteCode: '',
+          materialName: '',
           category: '',
           province: '',
           city: '',
@@ -159,8 +217,68 @@
           orders: []
         },
         loading: false,
-        // 采集按钮的 loading,避免重复点击
-        syncing: false
+        // 单次区间采集允许的最大时间总跨度(天),与后端 material-price.range.max-days 保持一致
+        maxRangeDays: 180,
+        // 历史区间采集弹窗:开始/结束日期分开选
+        rangeDialogVisible: false,
+        rangeStartDate: '',
+        rangeEndDate: '',
+        rangeSubmitting: false,
+        // 后端锁或本进程标记为执行中
+        rangeRunning: false,
+        // 最近一次区间采集日志(material_price_sync_log)
+        rangeLog: {
+          batchNo: '',
+          status: '',
+          progressDone: 0,
+          progressTotal: 0,
+          currentStage: '',
+          savedCount: 0,
+          failedCount: 0,
+          errorMsg: ''
+        },
+        // 本次正在观察的批次号;用来区分「刚提交的批次」和历史批次,避免重复提示
+        watchingBatchNo: '',
+        // 已经提示过结果的批次号
+        finishedBatchNo: '',
+        // 进度轮询定时器
+        pollTimer: null
+      }
+    },
+    computed: {
+      // 进度条百分比:执行中最多显示 99%,避免没完全结束就显示 100%
+      rangePercent () {
+        const total = this.rangeLog.progressTotal || 0
+        if (!total) return 0
+        return Math.min(99, Math.floor((this.rangeLog.progressDone || 0) * 100 / total))
+      },
+      rangeDone () {
+        return this.rangeLog.progressDone || 0
+      },
+      rangeTotal () {
+        return this.rangeLog.progressTotal || 0
+      },
+      rangeCurrentStage () {
+        return this.rangeLog.currentStage || ''
+      },
+      // 弹窗里展示的「最近一次采集」摘要
+      lastRangeSummary () {
+        const log = this.rangeLog
+        if (!log || !log.batchNo || log.status === 'RUNNING') return ''
+        const text = { SUCCESS: '成功', PARTIAL: '部分失败', NO_DATA: '无数据', FAILED: '失败' }[log.status] || log.status
+        return `最近一次:${log.rangeStart || '?'} 至 ${log.rangeEnd || '?'},${text},入库 ${log.savedCount || 0} 条`
+      },
+    },
+    watch: {
+      // 改开始日期后,如果已选的结束日期早于开始日期或跨度超限,就清掉让用户重选
+      rangeStartDate (value) {
+        if (!value || !this.rangeEndDate) return
+        const start = new Date(value + 'T00:00:00')
+        const latest = new Date(start.getTime() + (this.maxRangeDays - 1) * 24 * 3600 * 1000)
+        const end = new Date(this.rangeEndDate + 'T00:00:00')
+        if (end.getTime() < start.getTime() || end.getTime() > latest.getTime()) {
+          this.rangeEndDate = ''
+        }
       }
     },
     materialPriceService: null,
@@ -173,10 +291,21 @@
     },
     mounted () {
       this.refreshList()
+      // 页面刷新时可能刚好有区间任务在跑,恢复轮询
+      this.resumeRangeWatch()
     },
     // 项目启用了 keep-alive,从其它页面切回来时刷新一次
     activated () {
       this.refreshList()
+      this.resumeRangeWatch()
+    },
+    // 离开页面时停止轮询,避免后台一直发请求
+    deactivated () {
+      this.stopPolling()
+    },
+    // Vue 3 用 beforeUnmount,写 beforeDestroy 不会触发,轮询定时器会留到页面销毁后
+    beforeUnmount () {
+      this.stopPolling()
     },
     methods: {
       // 拉取下拉选项:传 siteCode/category/province 做级联收窄
@@ -246,30 +375,167 @@
         this.loadOptions()
         this.refreshList()
       },
-      // 手动采集:确认后调用后端,部分站点失败仍算成功但会提示
-      syncNow () {
-        this.$confirm('将立即从三个网站抓取最新材料价格,可能耗时 1 分钟左右,是否继续?', '提示', {
-          confirmButtonText: '确定',
-          cancelButtonText: '取消',
-          type: 'warning'
-        }).then(() => {
-          this.syncing = true
-          this.materialPriceService.sync().then((res) => {
-            this.syncing = false
-            if (res && res.code === 200) {
-              const suffix = res.partial ? '(部分站点抓取失败)' : ''
-              this.$message.success(`采集完成,入库 ${res.saved || 0} 条${suffix}`)
-              this.tablePage.currentPage = 1
-              this.loadOptions()
-              this.refreshList()
-            } else {
-              this.$message.error((res && res.msg) || '采集失败')
+      // 开始日期限制:只能选昨天及更早;已选结束日期时,还要保证 start <= end 且跨度不超过 maxRangeDays
+      disabledStartDate (time) {
+        const today = new Date()
+        today.setHours(0, 0, 0, 0)
+        if (time.getTime() >= today.getTime()) {
+          return true
+        }
+        if (this.rangeEndDate) {
+          const end = new Date(this.rangeEndDate + 'T00:00:00').getTime()
+          const earliest = end - (this.maxRangeDays - 1) * 24 * 3600 * 1000
+          return time.getTime() > end || time.getTime() < earliest
+        }
+        return false
+      },
+      // 结束日期限制:必须早于今天(最晚昨天);已选开始日期时,还要落在 [start, start + maxRangeDays - 1] 之内
+      disabledEndDate (time) {
+        const today = new Date()
+        today.setHours(0, 0, 0, 0)
+        if (time.getTime() >= today.getTime()) {
+          return true
+        }
+        if (this.rangeStartDate) {
+          const start = new Date(this.rangeStartDate + 'T00:00:00').getTime()
+          const latest = start + (this.maxRangeDays - 1) * 24 * 3600 * 1000
+          return time.getTime() < start || time.getTime() > latest
+        }
+        return false
+      },
+      // 打开历史采集弹窗:日期默认留空,必须手动选择;同时探测一次后端状态
+      openRangeDialog () {
+        this.rangeStartDate = ''
+        this.rangeEndDate = ''
+        this.rangeDialogVisible = true
+        this.fetchRangeStatus()
+      },
+      // 提交区间采集:接口立即返回,之后靠轮询拿进度
+      submitRange () {
+        if (!this.rangeStartDate || !this.rangeEndDate) {
+          this.$message.warning('请选择开始日期和结束日期')
+          return
+        }
+        if (this.rangeEndDate < this.rangeStartDate) {
+          this.$message.warning('结束日期不能早于开始日期')
+          return
+        }
+        // 结束日期必须早于今天,今天的增量交给定时任务
+        const today = new Date()
+        today.setHours(0, 0, 0, 0)
+        if (new Date(this.rangeEndDate + 'T00:00:00').getTime() >= today.getTime()) {
+          this.$message.warning('结束日期必须早于今天')
+          return
+        }
+        const startDate = this.rangeStartDate
+        const endDate = this.rangeEndDate
+        // 后端会再校验一次跨度上限,这里提前给出提示
+        const days = Math.round((new Date(endDate + 'T00:00:00') - new Date(startDate + 'T00:00:00')) / 86400000) + 1
+        if (days > this.maxRangeDays) {
+          this.$message.warning(`单次采集跨度最多 ${this.maxRangeDays} 天,当前选择 ${days} 天`)
+          return
+        }
+        this.rangeSubmitting = true
+        this.materialPriceService.syncRange(startDate, endDate).then((res) => {
+          this.rangeSubmitting = false
+          if (res && res.code === 200) {
+            // 记录本次批次,轮询结束后只对这个批次弹一次结果
+            this.watchingBatchNo = res.batchNo
+            this.finishedBatchNo = ''
+            this.rangeRunning = true
+            this.rangeLog = {
+              batchNo: res.batchNo,
+              status: 'RUNNING',
+              progressDone: res.progressDone || 0,
+              progressTotal: res.progressTotal || 0,
+              currentStage: '排队中',
+              savedCount: 0,
+              failedCount: 0,
+              errorMsg: ''
+            }
+            this.startPolling()
+            this.$message.success('已提交,正在后台采集')
+          } else {
+            this.$message.error((res && res.msg) || '提交失败')
+          }
+        }).catch(() => {
+          this.rangeSubmitting = false
+        })
+      },
+      // 每 3 秒查一次进度;间隔可按需调整
+      startPolling () {
+        this.stopPolling()
+        this.pollTimer = setInterval(() => {
+          this.fetchRangeStatus()
+        }, 3000)
+      },
+      stopPolling () {
+        if (this.pollTimer) {
+          clearInterval(this.pollTimer)
+          this.pollTimer = null
+        }
+      },
+      // 查询最近一次区间采集状态;结束后停止轮询并提示结果
+      fetchRangeStatus () {
+        this.materialPriceService.syncStatus().then((res) => {
+          if (!res) return
+          // 天数上限以后端配置为准,失败时沿用本地默认值
+          if (res.maxDays) {
+            this.maxRangeDays = res.maxDays
+          }
+          this.rangeRunning = !!res.running
+          if (res.log) {
+            this.rangeLog = res.log
+            if (this.rangeRunning && !this.watchingBatchNo) {
+              this.watchingBatchNo = res.log.batchNo
             }
-          }).catch(() => {
-            this.syncing = false
-          })
+          }
+          if (this.rangeRunning) {
+            if (!this.pollTimer) {
+              this.startPolling()
+            }
+          } else {
+            this.stopPolling()
+            this.handleRangeFinished()
+          }
         }).catch(() => {})
       },
+      // 页面激活时检查是否有区间任务在跑,有则恢复轮询
+      resumeRangeWatch () {
+        this.materialPriceService.syncRunning().then((res) => {
+          if (res && res.running) {
+            this.rangeRunning = true
+            this.watchingBatchNo = res.batchNo || this.watchingBatchNo
+            this.startPolling()
+          }
+        }).catch(() => {})
+      },
+      // 区间采集结束:按状态提示,并刷新列表与下拉选项
+      handleRangeFinished () {
+        const log = this.rangeLog || {}
+        if (!log.batchNo || log.status === 'RUNNING' || log.batchNo === this.finishedBatchNo) {
+          return
+        }
+        // 不是本次观察的批次(比如打开页面时看到的旧批次),只记录不弹提示
+        if (log.batchNo !== this.watchingBatchNo) {
+          this.watchingBatchNo = log.batchNo
+          return
+        }
+        this.finishedBatchNo = log.batchNo
+        const saved = log.savedCount || 0
+        if (log.status === 'SUCCESS') {
+          this.$message.success(`区间采集完成,入库 ${saved} 条`)
+        } else if (log.status === 'PARTIAL') {
+          this.$message.warning(`区间采集完成(部分分片失败),入库 ${saved} 条`)
+        } else if (log.status === 'NO_DATA') {
+          this.$message.info('区间采集完成,所选区间没有抓取到数据')
+        } else {
+          this.$message.error(`区间采集失败:${log.errorMsg || '未知原因'}`)
+        }
+        this.tablePage.currentPage = 1
+        this.loadOptions()
+        this.refreshList()
+      },
       // 打开走势弹窗,条件由当前行带入
       showTrend (row) {
         this.$refs.trendDialog.init(row)
@@ -285,3 +551,17 @@
     }
   }
 </script>
+
+<style scoped>
+  .range-line {
+    margin-top: 10px;
+    color: #606266;
+    font-size: 14px;
+  }
+  .range-tip {
+    margin-top: 10px;
+    color: #909399;
+    font-size: 13px;
+    line-height: 20px;
+  }
+</style>