traffic-audit-web/src/views/DataView.vue
@@ -1,9 +1,9 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 数据查看</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 数据查看"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/data">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
@@ -12,76 +12,182 @@
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
        </el-menu>
</el-menu>
      </el-aside>
      <el-main>
        <h3>导入数据查看</h3>
        <el-form :inline="true">
          <el-form-item label="数据类型">
            <el-radio-group v-model="type" @change="loadData">
              <el-radio-button label="h2032">H203-2月报</el-radio-button>
              <el-radio-button label="transportAuth">运政车辆</el-radio-button>
              <el-radio-button label="trackMileage">轨迹里程</el-radio-button>
              <el-radio-button label="scaleSplit">规上规下拆分</el-radio-button>
              <el-radio-button label="freightTurnover">货运量周转量</el-radio-button>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="报表期">
            <el-date-picker v-model="period" type="month" placeholder="全部" value-format="yyyy-MM" clearable style="width:140px" @change="loadData"></el-date-picker>
          </el-form-item>
          <el-form-item label="关键字">
            <el-input v-model="keyword" placeholder="企业/市州名称" clearable style="width:200px" @keyup.enter.native="loadData"></el-input>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="loadData">查询</el-button>
          </el-form-item>
        </el-form>
        <div style="color:#909399;font-size:12px;margin-bottom:6px">
          共 {{ total }} 条记录,当前第 {{ page }} 页
        <div class="toolbar">
          <span class="toolbar-label">报表期</span>
          <el-date-picker v-model="period" type="month" placeholder="报表期(全部)" value-format="yyyy-MM" clearable style="width:150px" @change="doQuery"></el-date-picker>
          <span class="toolbar-sep"></span>
          <span class="toolbar-label">数据类型</span>
          <el-select v-model="type" size="small" style="width:300px" placeholder="选择数据类型" filterable
                     popper-class="data-type-select" :title="currentTypeLabel" @change="onTypeSelect">
            <el-option-group v-for="g in modules" :key="g.key" :label="g.name">
              <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
            </el-option-group>
          </el-select>
          <template v-if="hasPeriodType">
            <span class="toolbar-label">口径</span>
            <el-select v-model="periodType" size="small" style="width:110px" @change="doQuery">
              <el-option label="全部" value=""></el-option>
              <el-option label="当月" value="MONTH"></el-option>
              <el-option label="累计" value="CUMULATIVE"></el-option>
            </el-select>
          </template>
          <el-input v-model="keyword" placeholder="企业/市州名称" clearable style="width:180px" @keyup.enter.native="doQuery"></el-input>
          <el-button type="primary" size="small" @click="doQuery">查询</el-button>
          <el-button size="small" icon="el-icon-full-screen" @click="toggleFullscreen">全屏查看</el-button>
        </div>
        <div class="table-area">
        <el-table :data="records" border v-loading="loading" size="small" style="width:100%" height="100%">
          <el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :min-width="c.width" show-overflow-tooltip>
            <template slot-scope="scope">
              <span>{{ formatCell(scope.row[c.prop]) }}</span>
            </template>
          </el-table-column>
        </el-table>
        <div class="load-hint">共 {{ total }} 条记录,已加载 {{ records.length }} 条</div>
        <div class="table-wrap" ref="tableWrap">
          <div class="table-area">
            <el-table :data="records" border v-loading="loading" size="small" style="width:100%" height="100%">
              <el-table-column v-for="c in renderColumns" :key="c.prop" :prop="c.prop" :label="c.label" :min-width="c.width" :fixed="c.fixed ? 'left' : false" show-overflow-tooltip>
                <template slot-scope="scope">
                  <span>{{ formatCell(scope.row[c.prop], c.prop) }}</span>
                </template>
              </el-table-column>
            </el-table>
          </div>
        </div>
        <el-pagination style="margin-top:15px;text-align:right"
          background layout="total, prev, pager, next, sizes"
          :total="total" :current-page.sync="page" :page-size.sync="size"
          :page-sizes="[20, 50, 100]" @current-change="loadData" @size-change="loadData">
        </el-pagination>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'DataView',
  data() {
    return {
      modules: [
        {
          key: 'freight', name: '道路货运', types: [
            { value: 'h2032', label: '道路货物运输月度生产情况' },
            { value: 'transportAuth', label: '运政车辆' },
            { value: 'trackMileage', label: '轨迹里程' },
            { value: 'scaleSplit', label: '规上规下拆分' },
            { value: 'freightTurnover', label: '货运量周转量' }
          ]
        },
        {
          key: 'passenger', name: '公路旅客', types: [
            { value: 'h2031', label: '公路旅客运输月度生产情况' },
            { value: 'passengerAuth', label: '运政车辆数' },
            { value: 'passengerIndividual', label: '个体客运量/周转量' }
          ]
        },
        {
          key: 'city', name: '城市客运', types: [
            { value: 'cityBus', label: '城市公共交通月度运营情况' },
            { value: 'cityTaxi', label: '巡游出租汽车运营服务情况月报' },
            { value: 'cityTaxiAuth', label: '出租车运政车辆' },
            { value: 'wycTotal', label: '网约车总数' },
            { value: 'wycOrder', label: '网约车订单及全省总量' }
          ]
        },
        {
          key: 'invest', name: '投资', types: [
            { value: 'investmentProject', label: '投资项目基础表' },
            { value: 'investmentMonthly', label: '市州月报项目进度' },
            { value: 'investmentSystem', label: '投资系统每月导入项目表' }
          ]
        },
        {
          key: 'energy', name: '能耗', types: [
            { value: 'energyVehicle', label: '车辆能耗季度数据' },
            { value: 'energyAuth', label: '能耗运政车辆数' }
          ]
        }
      ],
      activeModule: 'freight',
      type: 'h2032',
      period: '',
      periodType: '',
      keyword: '',
      page: 1,
      size: 20,
      size: 50,
      total: 0,
      records: [],
      columns: [],
      loading: false
      loading: false,
      loadingMore: false
    }
  },
  computed: {
    // 规上规下拆分等表有"口径"列(当月/累计),把它做成上方筛选而不是表格列
    hasPeriodType() {
      return (this.columns || []).some(c => c.prop === 'periodType')
    },
    currentTypeLabel() {
      for (const g of this.modules) {
        const t = g.types.find(x => x.value === this.type)
        if (t) return g.name + ' · ' + t.label
      }
      return this.type
    },
    activeTypes() {
      const g = this.modules.find(x => x.key === this.activeModule) || this.modules[0]
      return g
    },
    visibleColumns() {
      return (this.columns || []).filter(c => this.keepColumn(c))
    },
    // 左侧冻结"这是谁的数据"的列:市州/地区 + 企业(项目)名称/车牌号,最多 3 列。
    // 报表期与所属地区不参与冻结(报表期是筛选条件,所属地区是区划代码,已由市州列替代)。
    renderColumns() {
      const stickyProps = ['city', 'regionName', 'region', 'periodType', 'enterpriseName', 'projectName', 'plateNo']
      const cols = this.visibleColumns
      let count = 0
      while (count < cols.length && count < 3 && stickyProps.indexOf(cols[count].prop) >= 0) {
        count++
      }
      return cols.map((c, i) => Object.assign({}, c, { fixed: i < count }))
    }
  },
  methods: {
    // 数据查看统一列过滤:内部标识列不展示;报表期(由筛选条件体现)与所属地区(区划代码,已被市州列替代)不展示
    keepColumn(c) {
      if (!c) return false
      // 口径列上移为筛选;拆分表货运量三列源文件未提供(全空)故不展示
      const hiddenProps = ['enterpriseCode', 'unifiedCreditCode', 'reportUnit', 'projectId', 'regionCode', 'reportPeriod',
        'periodType', 'aboveScaleFreight', 'belowScaleFreight', 'totalFreight']
      const hiddenLabels = ['企业代码', '统一社会信用代码', '填报单位', '项目ID', '所属地区', '报表期', '口径']
      if (hiddenProps.indexOf(c.prop) >= 0 || hiddenLabels.indexOf(c.label) >= 0) return false
      return true
    },
    onModuleChange(key) {
      const g = this.modules.find(x => x.key === key)
      if (!g || !g.types.length) return
      if (!g.types.some(t => t.value === this.type)) {
        this.type = g.types[0].value
      }
      this.doQuery()
    },
    onTypeSelect() {
      const g = this.modules.find(x => x.types.some(t => t.value === this.type))
      if (g) this.activeModule = g.key
      this.periodType = ''
      this.doQuery()
    },
    doQuery() {
      this.page = 1
      this.records = []
      this.loadData()
    },
    loadData() {
      this.loading = true
      this.loadingMore = false
      api.get('/data/list', {
        params: {
          type: this.type,
          period: this.period || undefined,
          keyword: this.keyword || undefined,
          periodType: this.periodType || undefined,
          page: this.page,
          size: this.size
        }
@@ -94,22 +200,156 @@
          this.page = 1
          this.loadData()
        }
      }).catch(() => {
      }).catch(e => {
        if (e && e._toast) return
        this.$message.error('数据查询失败')
      }).finally(() => {
        this.loading = false
        this.$nextTick(() => this.attachBodyScroll())
      })
    },
    formatCell(v) {
    // el-table 固定高度后,真正的竖向滚动容器是内部的 body-wrapper(页面本身不滚动)
    attachBodyScroll() {
      const wrap = this.$refs.tableWrap
      if (!wrap) return
      const bw = wrap.querySelector('.el-table__body-wrapper')
      if (bw && bw._vwScrollHandler !== this.onScroll) {
        if (bw._vwScrollHandler) bw.removeEventListener('scroll', bw._vwScrollHandler)
        bw.addEventListener('scroll', this.onScroll, { passive: true })
        bw._vwScrollHandler = this.onScroll
      }
    },
    // 滚动到底部自动追加下一页(保留手动分页跳页能力)
    loadMore() {
      if (this.loading || this.loadingMore) return
      if (this.records.length >= this.total || this.total <= 0) return
      const nextPage = this.page + 1
      this.loadingMore = true
      api.get('/data/list', {
        params: {
          type: this.type,
          period: this.period || undefined,
          keyword: this.keyword || undefined,
          periodType: this.periodType || undefined,
          page: nextPage,
          size: this.size
        }
      }).then(res => {
        const d = res.data || {}
        const addRows = d.records || []
        if (!addRows.length) {
          this.page = nextPage - 1
          return
        }
        this.records = this.records.concat(addRows)
        this.page = nextPage
        if (d.total != null) this.total = d.total
        if (d.columns && d.columns.length) this.columns = d.columns
      }).catch(e => {
        if (e && e._toast) return
        this.$message.error('加载更多失败')
      }).finally(() => {
        this.loadingMore = false
        this.$nextTick(() => this.attachBodyScroll())
      })
    },
    onScroll(e) {
      // 普通/全屏下真正的滚动容器都是 el-table 内部 body-wrapper(捕获阶段可收到);
      // 页面整体滚动时以 window/document 为准。
      const t = e && e.target
      let scrollTop, clientHeight, scrollHeight
      if (t && t.nodeType === 1 && t !== document && typeof t.scrollTop === 'number' && t.scrollHeight > t.clientHeight) {
        scrollTop = t.scrollTop
        clientHeight = t.clientHeight
        scrollHeight = t.scrollHeight
      } else {
        const se = document.scrollingElement || document.documentElement
        scrollTop = se.scrollTop
        clientHeight = window.innerHeight
        scrollHeight = se.scrollHeight
      }
      if (scrollHeight > 0 && scrollTop + clientHeight >= scrollHeight - 80) {
        this.loadMore()
      }
    },
    toggleFullscreen() {
      const el = this.$refs.tableWrap
      if (!document.fullscreenElement) {
        if (el && el.requestFullscreen) el.requestFullscreen()
        else this.$message.warning("浏览器不支持全屏")
      } else if (document.exitFullscreen) {
        document.exitFullscreen()
      }
    },
    // 显示格式化(不改动底层数据,导出仍为全精度):
    //  - 比率类列(环比/同比/增速/占比)库内是小数(0.045 = 4.5%),显示为百分比;
    //  - 排名类列显示整数;
    //  - 其余数值千分位、保留两位小数;整数原样。
    formatCell(v, prop) {
      if (v === null || v === undefined || v === '') return '-'
      return v
      const s = typeof v === 'string' ? v.trim() : v
      if (typeof v !== 'number' && !/^-?\d+(\.\d+)?$/.test(String(s))) return v
      const n = Number(s)
      if (!isFinite(n)) return v
      const key = String(prop || '').toLowerCase()
      if (/rank/.test(key)) return Math.round(n).toLocaleString('zh-CN')
      if (/(rate|yoy|mom|growth|ratio|percent|share)/.test(key)) return (n * 100).toFixed(2) + '%'
      if (Number.isInteger(n)) return n.toLocaleString('zh-CN')
      return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
    }
  },
  mounted() {
    const q = this.$route.query
    if (q.type) this.type = q.type
    if (q.period) this.period = q.period
    if (q.keyword) this.keyword = q.keyword
    this.loadData()
    window.addEventListener('scroll', this.onScroll, { passive: true })
    if (this.$refs.tableWrap) {
      // capture=true:el-table 内部 body-wrapper 的滚动在捕获阶段也能收到
      this.$refs.tableWrap.addEventListener('scroll', this.onScroll, { passive: true, capture: true })
    }
    this.$nextTick(() => this.attachBodyScroll())
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this.onScroll)
    if (this.$refs.tableWrap) {
      this.$refs.tableWrap.removeEventListener('scroll', this.onScroll, { capture: true })
      const bw = this.$refs.tableWrap.querySelector('.el-table__body-wrapper')
      if (bw && bw._vwScrollHandler) bw.removeEventListener('scroll', bw._vwScrollHandler)
    }
  }
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
.type-row { margin-bottom: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.type-label { color: #606266; font-weight: 600; }
.filter-row { margin-bottom: 4px; }
#app .toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:8px 12px; background:#FAFAFA; border:1px solid #EBEEF5; border-radius:8px; margin-bottom:8px; }
#app .toolbar-label { color:#606266; font-weight:600; white-space:nowrap; }
#app .toolbar-sep { width:1px; height:22px; background:#DCDFE6; margin:0 2px; }
#app .load-hint { color:#909399; font-size:12px; margin-bottom:6px; }
#app .table-wrap { display:flex; flex-direction:column; height:calc(100vh - 250px); min-height:300px; }
#app .table-area { flex:1 1 auto; min-height:0; overflow:hidden; }
#app .table-area .el-table { width:100%; height:100%; }
#app .table-area:fullscreen { padding:20px; background:#fff; overflow:auto; }
#app .table-wrap:fullscreen { padding:20px; background:#fff; overflow:auto; display:flex; flex-direction:column; height:100vh; }
#app .table-wrap:fullscreen .table-area { flex:1 1 auto; min-height:0; }
</style>
<style>
/* 数据类型下拉:Element UI 默认把分组标题做成 12px 灰色小字,看起来"比选项更小更淡"。
   这里把它改成明确的组标题样式(加深、加粗、略大、带底色),与选项形成有意的层级。 */
.data-type-select .el-select-group__title {
  font-size: 13px;
  font-weight: 700;
  color: #303133;
  line-height: 28px;
  background: #F5F7FA;
  padding-left: 12px;
  letter-spacing: .5px;
}
.data-type-select .el-select-group__title::after { display: none; }
.data-type-select .el-select-group { padding-bottom: 6px; }
.data-type-select .el-select-group .el-select-dropdown__item { padding-left: 20px; }
</style>