Bladeren bron

物料抓取

Alex 1 week geleden
bovenliggende
commit
00677f8232

+ 46 - 0
src/api/program/MaterialPriceService.js

@@ -0,0 +1,46 @@
+import request from '@/utils/httpRequest'
+import { ASSESS_PATH as prefix } from '../AppPath'
+
+/**
+ * 材料价格接口。
+ *
+ * 前端只调 Java(Gateway /assess-server -> jeeplus-assess),
+ * 不直接访问 Python 抓取服务。
+ */
+export default class MaterialPriceService {
+  // 筛选框下拉选项(站点/分类/省份/城市/物料),支持按站点、分类、省份级联
+  options (params) {
+    return request({
+      url: prefix + '/materialPrice/record/options',
+      method: 'get',
+      params: params
+    })
+  }
+
+  // 分页列表;priceDates 数组由 httpRequest 序列化为 priceDates[0]/priceDates[1]
+  list (params) {
+    return request({
+      url: prefix + '/materialPrice/record/list',
+      method: 'get',
+      params: params
+    })
+  }
+
+  // 单站点 + 单物料的历史价格点,供走势图使用
+  history (params) {
+    return request({
+      url: prefix + '/materialPrice/record/history',
+      method: 'get',
+      params: params
+    })
+  }
+
+  // 管理员手动采集;date 为空表示取各站点最新数据
+  sync (date) {
+    return request({
+      url: prefix + '/materialPrice/record/sync',
+      method: 'post',
+      params: date ? { date: date } : {}
+    })
+  }
+}

+ 287 - 0
src/views/program/materialPrice/MaterialPriceList.vue

@@ -0,0 +1,287 @@
+<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>
+      <el-form-item label="分类" prop="category">
+        <el-select v-model="searchForm.category" placeholder="全部" clearable 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-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-option v-for="item in options.cities" :key="item" :label="item" :value="item"></el-option>
+        </el-select>
+      </el-form-item>
+      <!-- 数据日期区间:数组由 httpRequest 序列化成 priceDates[0]/priceDates[1] -->
+      <el-form-item label="数据日期" prop="priceDates">
+        <el-date-picker
+          v-model="searchForm.priceDates"
+          type="daterange"
+          value-format="YYYY-MM-DD"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          style="width: 250px">
+        </el-date-picker>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="refreshList()" icon="el-icon-search">查询</el-button>
+        <el-button @click="resetSearch()" icon="el-icon-refresh-right">重置</el-button>
+      </el-form-item>
+    </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>
+        </template>
+      </vxe-toolbar>
+      <div class="jp-table-body">
+        <vxe-table
+          border="inner"
+          auto-resize
+          resizable
+          height="auto"
+          :loading="loading"
+          size="small"
+          ref="materialPriceTable"
+          show-header-overflow
+          show-overflow
+          highlight-hover-row
+          :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>
+          <vxe-column title="城市" width="100" align="left">
+            <template #default="scope">{{ scope.row.city || '—' }}</template>
+          </vxe-column>
+          <vxe-column title="物料" field="materialName" min-width="150" align="left"></vxe-column>
+          <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>
+          <vxe-column title="最低价" width="100" align="right">
+            <template #default="scope">{{ formatPrice(scope.row.priceLow) }}</template>
+          </vxe-column>
+          <vxe-column title="最高价" width="100" align="right">
+            <template #default="scope">{{ formatPrice(scope.row.priceHigh) }}</template>
+          </vxe-column>
+          <vxe-column title="单位" field="priceUnit" width="80" align="center"></vxe-column>
+          <!-- 含税三态:1 含税 / 0 不含税 / null 未声明 -->
+          <vxe-column title="含税" width="90" align="center">
+            <template #default="scope">
+              <el-tag v-if="scope.row.taxIncluded === 1" size="small" type="success">含税</el-tag>
+              <el-tag v-else-if="scope.row.taxIncluded === 0" size="small" type="warning">不含税</el-tag>
+              <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">
+              <el-link v-if="scope.row.sourceUrl" type="primary" :href="scope.row.sourceUrl" target="_blank">链接</el-link>
+              <span v-else>—</span>
+            </template>
+          </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>
+            </template>
+          </vxe-column>
+        </vxe-table>
+        <vxe-pager
+          background
+          size="small"
+          :current-page="tablePage.currentPage"
+          :page-size="tablePage.pageSize"
+          :total="tablePage.total"
+          :page-sizes="[10, 20, 100, 1000]"
+          :layouts="['PrevPage', 'JumpNumber', 'NextPage', 'FullJump', 'Sizes', 'Total']"
+          @page-change="currentChangeHandle">
+        </vxe-pager>
+      </div>
+    </div>
+    <MaterialPriceTrendDialog ref="trendDialog"></MaterialPriceTrendDialog>
+  </div>
+</template>
+
+<script>
+  import MaterialPriceService from '@/api/program/MaterialPriceService'
+  import MaterialPriceTrendDialog from './MaterialPriceTrendDialog'
+
+  export default {
+    data () {
+      return {
+        // 查询条件;priceDates 为空数组时后端不会按日期过滤
+        searchForm: {
+          siteCode: '',
+          category: '',
+          province: '',
+          city: '',
+          priceDates: []
+        },
+        // 下拉选项,全部由后端 /options 返回
+        options: {
+          sites: [],
+          categories: [],
+          provinces: [],
+          cities: []
+        },
+        dataList: [],
+        tablePage: {
+          total: 0,
+          currentPage: 1,
+          pageSize: 10,
+          orders: []
+        },
+        loading: false,
+        // 采集按钮的 loading,避免重复点击
+        syncing: false
+      }
+    },
+    materialPriceService: null,
+    created () {
+      this.materialPriceService = new MaterialPriceService()
+      this.loadOptions()
+    },
+    components: {
+      MaterialPriceTrendDialog
+    },
+    mounted () {
+      this.refreshList()
+    },
+    // 项目启用了 keep-alive,从其它页面切回来时刷新一次
+    activated () {
+      this.refreshList()
+    },
+    methods: {
+      // 拉取下拉选项:传 siteCode/category/province 做级联收窄
+      loadOptions () {
+        this.materialPriceService.options({
+          siteCode: this.searchForm.siteCode,
+          category: this.searchForm.category,
+          province: this.searchForm.province
+        }).then((data) => {
+          this.options = {
+            sites: (data && data.sites) || [],
+            categories: (data && data.categories) || [],
+            provinces: (data && data.provinces) || [],
+            cities: (data && data.cities) || []
+          }
+        })
+      },
+      // 换站点后分类、省份、城市都失效,清空后重新拉选项
+      handleSiteChange () {
+        this.searchForm.category = ''
+        this.searchForm.province = ''
+        this.searchForm.city = ''
+        this.loadOptions()
+      },
+      // 换分类后省份、城市随之收窄
+      handleCategoryChange () {
+        this.searchForm.province = ''
+        this.searchForm.city = ''
+        this.loadOptions()
+      },
+      // 换省份后城市列表变化,先清空已选城市
+      handleProvinceChange () {
+        this.searchForm.city = ''
+        this.loadOptions()
+      },
+      // 分页查询;空值条件直接剔除,避免后端收到空串
+      refreshList () {
+        this.loading = true
+        const params = {
+          current: this.tablePage.currentPage,
+          size: this.tablePage.pageSize,
+          orders: this.tablePage.orders,
+          ...this.searchForm
+        }
+        if (!params.priceDates || params.priceDates.length === 0) {
+          delete params.priceDates
+        }
+        this.materialPriceService.list(params).then((data) => {
+          this.dataList = data.records || []
+          this.tablePage.total = data.total || 0
+          this.loading = false
+        }).catch(() => {
+          this.loading = false
+        })
+      },
+      // vxe-pager 翻页/改每页条数
+      currentChangeHandle ({ currentPage, pageSize }) {
+        this.tablePage.currentPage = currentPage
+        this.tablePage.pageSize = pageSize
+        this.refreshList()
+      },
+      // 重置全部条件并回到第一页
+      resetSearch () {
+        this.$refs.searchForm.resetFields()
+        this.searchForm.priceDates = []
+        this.tablePage.currentPage = 1
+        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) || '采集失败')
+            }
+          }).catch(() => {
+            this.syncing = false
+          })
+        }).catch(() => {})
+      },
+      // 打开走势弹窗,条件由当前行带入
+      showTrend (row) {
+        this.$refs.trendDialog.init(row)
+      },
+      // 价格为空显示「—」,有值统一保留两位小数
+      formatPrice (value) {
+        if (value === null || value === undefined || value === '') {
+          return '—'
+        }
+        const num = Number(value)
+        return isNaN(num) ? value : num.toFixed(2)
+      }
+    }
+  }
+</script>

+ 225 - 0
src/views/program/materialPrice/MaterialPriceTrendDialog.vue

@@ -0,0 +1,225 @@
+<template>
+  <el-dialog
+    :title="title"
+    v-model="visible"
+    width="900px"
+    :close-on-click-modal="false"
+    draggable
+    @close="close">
+    <!-- 表头信息:省份/城市固定为列表行上的值,弹窗内不再提供筛选 -->
+    <div class="trend-header">
+      <span>站点:{{ query.siteName || '—' }}</span>
+      <span>分类:{{ query.category || '—' }}</span>
+      <span>物料:{{ query.materialName || '—' }}</span>
+      <span>省份:{{ query.province || '—' }}</span>
+      <span>城市:{{ query.city || '—' }}</span>
+      <span>含税:{{ taxText }}</span>
+    </div>
+    <div class="trend-query">
+      <el-date-picker
+        v-model="dates"
+        type="daterange"
+        value-format="YYYY-MM-DD"
+        range-separator="至"
+        start-placeholder="开始日期"
+        end-placeholder="结束日期"
+        style="width: 260px">
+      </el-date-picker>
+      <el-button type="primary" icon="el-icon-search" @click="loadData()">查询</el-button>
+      <!-- 同一组数据出现多个单位时不换算,只提示按规格筛选 -->
+      <span v-if="unitMismatch" class="unit-warning">单位不一致,请按规格筛选</span>
+    </div>
+    <div v-if="!loading && points.length === 0">
+      <el-empty description="暂无历史价格"></el-empty>
+    </div>
+    <div v-else v-loading="loading">
+      <v-chart ref="trendChart" height="360px" :option="chartOption"></v-chart>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  import MaterialPriceService from '@/api/program/MaterialPriceService'
+
+  export default {
+    data () {
+      return {
+        visible: false,
+        loading: false,
+        title: '价格走势',
+        // 由列表行带入的查询条件;taxIncluded 为 null 时不传给后端
+        query: {
+          siteCode: '',
+          siteName: '',
+          category: '',
+          province: '',
+          city: '',
+          materialName: '',
+          spec: '',
+          taxIncluded: null
+        },
+        dates: [],
+        points: [],
+        chartOption: { series: [] }
+      }
+    },
+    materialPriceService: null,
+    created () {
+      this.materialPriceService = new MaterialPriceService()
+    },
+    computed: {
+      // 含税三态文案
+      taxText () {
+        if (this.query.taxIncluded === 1) return '含税'
+        if (this.query.taxIncluded === 0) return '不含税'
+        return '未声明'
+      },
+      // 同一批数据里出现多个单位时提示(不做单位换算)
+      unitMismatch () {
+        const units = this.points.map(p => p.priceUnit).filter(unit => unit)
+        return new Set(units).size > 1
+      }
+    },
+    methods: {
+      /**
+       * 打开弹窗:保存当前行的筛选条件,默认查询最近 90 天。
+       * 必须带上分类/省份/城市/含税,否则同一天不同地区的数据点会在图上重叠。
+       */
+      init (row) {
+        this.query = {
+          siteCode: row.siteCode,
+          siteName: row.siteName,
+          category: row.category,
+          province: row.province,
+          city: row.city,
+          materialName: row.materialName,
+          spec: row.spec,
+          taxIncluded: row.taxIncluded
+        }
+        this.title = `价格走势 - ${row.siteName || ''} / ${row.category || ''} / ${row.materialName || ''}`
+        this.dates = [
+          this.moment().subtract(90, 'days').format('YYYY-MM-DD'),
+          this.moment().format('YYYY-MM-DD')
+        ]
+        this.points = []
+        this.chartOption = { series: [] }
+        this.visible = true
+        this.$nextTick(() => {
+          this.loadData()
+        })
+      },
+      // 查询历史价格点;站点与物料是必填条件
+      loadData () {
+        if (!this.query.siteCode || !this.query.materialName) {
+          return
+        }
+        const params = {
+          siteCode: this.query.siteCode,
+          materialName: this.query.materialName,
+          priceDates: this.dates || []
+        }
+        if (this.query.category) params.category = this.query.category
+        if (this.query.province) params.province = this.query.province
+        if (this.query.city) params.city = this.query.city
+        if (this.query.spec) params.spec = this.query.spec
+        // 含税为 null 时不传,表示不限制
+        if (this.query.taxIncluded !== null && this.query.taxIncluded !== undefined && this.query.taxIncluded !== '') {
+          params.taxIncluded = this.query.taxIncluded
+        }
+        this.loading = true
+        this.materialPriceService.history(params).then((data) => {
+          this.points = data || []
+          this.buildChart()
+          this.loading = false
+          // 弹窗刚打开时图表容器尺寸可能为 0,取到数据后强制 resize
+          this.$nextTick(() => {
+            this.$refs.trendChart && this.$refs.trendChart.resize()
+          })
+        }).catch(() => {
+          this.loading = false
+        })
+      },
+      /**
+       * 按数据实际存在的字段动态出线:
+       * 中果/回收商网(有 low/high)出两条线;废废网(只有 price)出一条线;
+       * 两种都有时三条线,不做字段合并。
+       */
+      buildChart () {
+        const points = this.points
+        if (points.length === 0) {
+          this.chartOption = { series: [] }
+          return
+        }
+        const hasPrice = points.some(p => p.price !== null && p.price !== undefined)
+        const hasRange = points.some(p => p.priceLow != null && p.priceHigh != null)
+        const series = []
+        if (hasRange) {
+          series.push({
+            name: '最低价',
+            type: 'line',
+            showSymbol: true,
+            data: points.map(p => (p.priceLow == null ? null : Number(p.priceLow)))
+          })
+          series.push({
+            name: '最高价',
+            type: 'line',
+            showSymbol: true,
+            data: points.map(p => (p.priceHigh == null ? null : Number(p.priceHigh)))
+          })
+        }
+        if (hasPrice) {
+          series.push({
+            name: '价格',
+            type: 'line',
+            showSymbol: true,
+            data: points.map(p => (p.price == null ? null : Number(p.price))),
+            areaStyle: { opacity: 0.08 }
+          })
+        }
+        this.chartOption = {
+          tooltip: { trigger: 'axis' },
+          legend: { data: series.map(s => s.name) },
+          grid: { left: 70, right: 30, top: 60, bottom: 50 },
+          xAxis: {
+            type: 'category',
+            boundaryGap: false,
+            data: points.map(p => p.priceDate)
+          },
+          yAxis: {
+            type: 'value',
+            scale: true,
+            // 单位取第一个数据点的单位;单位不一致时页面上方会提示
+            name: points[0] ? points[0].priceUnit : ''
+          },
+          series: series
+        }
+      },
+      // 关闭时清空数据,避免下次打开先闪上一组数据
+      close () {
+        this.points = []
+        this.chartOption = { series: [] }
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .trend-header {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 16px;
+    font-size: 13px;
+    color: #606266;
+    margin-bottom: 12px;
+  }
+  .trend-query {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 10px;
+  }
+  .unit-warning {
+    color: #e6a23c;
+    font-size: 13px;
+  }
+</style>