traffic-audit-web/src/views/ReportExport.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="activeMenu">
        <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,7 +12,6 @@
          <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-item index="/login" @click="logout">退出登录</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
@@ -26,12 +25,12 @@
            </div>
          </div>
          <!-- 第二行:报表类型 Tab(货运 / 旅客 / 投资 / 能耗 / 城市客运) -->
          <el-tabs v-model="activeKey" class="module-tabs">
            <el-tab-pane v-for="m in modules" :key="m.key" :name="m.key">
              <span slot="label">{{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag></span>
            </el-tab-pane>
          </el-tabs>
          <!-- 第二行:报表类型切换(货运 / 旅客 / 投资 / 能耗 / 城市客运);el-radio-group 替代 el-tabs,规避 el-tabs 布局无限循环(与 /import 修复一致) -->
          <el-radio-group v-model="activeKey" class="module-tabs">
            <el-radio-button v-for="m in visibleModules" :key="m.key" :label="m.key">
              {{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag>
            </el-radio-button>
          </el-radio-group>
          <!-- 第三行:查询条件区(周期 + 月份 + 查询) -->
          <section class="query-panel">
@@ -113,11 +112,30 @@
                <span v-if="!it.ready" class="cov-bad" style="margin-left:10px">{{ it.note }}</span>
                <span v-if="it.ready && it.audited === false" class="cov-bad" style="margin-left:10px">未审核</span>
              </div>
              <table class="preview-summary" v-if="it.summary && it.summary.length">
              <table class="preview-summary wb-table" v-if="it.key === 'summaryWorkbook' && it.summary && it.summary.length">
                <thead>
                  <tr><th style="width:44%">指标</th><th style="width:30%">数据</th><th>同比</th></tr>
                </thead>
                <tbody>
                  <template v-for="(sec, si) in wbRows(it.summary)">
                    <tr v-for="(row, ri) in sec.rows" :key="si + '-' + ri">
                      <td v-if="ri === 0" :rowspan="sec.rows.length" class="wb-sec">{{ sec.name }}</td>
                      <td class="wb-label">{{ row.label }}</td>
                      <td class="wb-value">{{ row.value === null ? '—' : fmtVal(row.value) }}</td>
                      <td class="wb-yoy" :class="{ 'yoy-empty': row.yoy === null }">{{ row.yoy === null ? '—' : row.yoy }}</td>
                    </tr>
                  </template>
                </tbody>
              </table>
              <div v-if="it.key === 'summaryWorkbook' && wbMissing(it.summary)" class="wb-note">
                部分指标缺少上年同期({{ wbPrevYear() }})基期数据,同比暂未计算
              </div>
              <table class="preview-summary" v-else-if="it.summary && it.summary.length">
                <tbody>
                  <tr v-for="(s, i) in it.summary" :key="i">
                    <td>{{ s.name }}</td>
                    <td>{{ s.label }}</td>
                    <td><span v-if="s.value === null || s.value === ''" class="cell-empty">(空)</span>{{ fmtVal(s.value) }}</td>
                    <td style="white-space:nowrap;color:#909399">{{ s.unit || '' }}</td>
                  </tr>
                </tbody>
              </table>
@@ -146,14 +164,15 @@
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'ReportExport',
  data() {
    return {
      loading: '',
      activeMenu: '/report',
      activeKey: '',
      updatedAt: '',
      // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy)
@@ -170,27 +189,33 @@
      noteDraft: '',
      modules: [
        {
          key: 'freight', title: '货运报表', items: [
          key: 'summary', title: '汇总大表', items: [
            { key: 'summaryWorkbook', label: '道路运输量汇总表(整本)', fileName: '生成_道路运输量汇总表.xlsx', status: null, statusNote: '' }
          ], checked: []
        },
        {
          key: 'freight', title: '货运报表', hidden: true, items: [
            { key: 'cityDetail', label: '货运量分市州明细', fileName: '生成_货运量分市州明细.xlsx', status: null, statusNote: '' },
            { key: 'freightRank', label: '货运量排名', fileName: '生成_货运量排名.xlsx', status: null, statusNote: '' },
            { key: 'turnoverRank', label: '周转量排名', fileName: '生成_周转量排名.xlsx', status: null, statusNote: '' }
          ], checked: []
        },
        {
          key: 'passenger', title: '旅客报表', items: [
          key: 'passenger', title: '旅客报表', hidden: true, items: [
            { key: 'passengerCityDetail', label: '公路旅客分市州', fileName: '生成_公路旅客分市州.xlsx', status: null, statusNote: '' },
            { key: 'passengerMidDetail', label: '中口径明细', fileName: '生成_公路旅客中口径明细.xlsx', status: null, statusNote: '' },
            { key: 'passengerMidRank', label: '中口径排名', fileName: '生成_公路旅客中口径排名.xlsx', status: null, statusNote: '' },
            { key: 'passengerMidAnalysis', label: '中口径分析', fileName: '生成_公路旅客中口径分析.xlsx', status: null, statusNote: '' }
          ], checked: []
        },        {
          key: 'cityPassenger', title: '城市客运报表', tag: '公交/出租/轨道轮渡/汇总', tagType: 'success', items: [
          key: 'cityPassenger', title: '城市客运报表', hidden: true, items: [
            { key: 'cityBusDetail', label: '城市公交客运量分市州明细', fileName: '生成_城市公交客运量分市州明细.xlsx', status: null, statusNote: '' },
            { key: 'cityTaxiDetail', label: '巡游出租客运量分市州明细', fileName: '生成_巡游出租客运量分市州明细.xlsx', status: null, statusNote: '' },
            { key: 'cityRailFerryDetail', label: '轨道轮渡客运量分市州明细', fileName: '生成_轨道轮渡客运量分市州明细.xlsx', status: null, statusNote: '' },
            { key: 'cityPassengerCitySum', label: '城市客运客运量分市州累计', fileName: '生成_城市客运客运量分市州累计.xlsx', status: null, statusNote: '' },
            { key: 'cityPassengerDetailSum', label: '城市客运各市州明细表', fileName: '生成_城市客运各市州明细表.xlsx', status: null, statusNote: '' },
            { key: 'cityPassengerSummary', label: '城市客运汇总', fileName: '生成_城市客运汇总.xlsx', status: null, statusNote: '' }
            { key: 'cityPassengerSummary', label: '城市客运汇总', fileName: '生成_城市客运汇总.xlsx', status: null, statusNote: '' },
            { key: 'wycSplit', label: '网约车客运量拆分表', fileName: '生成_网约车客运量拆分表.xlsx', status: null, statusNote: '' }
          ], checked: []
        },
        {
@@ -210,12 +235,15 @@
    }
  },
  computed: {
    username() { return localStorage.getItem('username') || '' },
    realName() { return localStorage.getItem('realName') || '' },
    username() { return sessionStorage.getItem('username') || '' },
    realName() { return sessionStorage.getItem('realName') || '' },
    myModules() {
      const s = localStorage.getItem('modules')
      const s = sessionStorage.getItem('modules')
      if (!s) return []
      return s.split(',').filter(Boolean)
    },
    visibleModules() {
      return this.modules.filter(m => !m.hidden)
    },
    curModule() {
      return this.modules.find(m => m.key === this.activeKey) || this.modules[0]
@@ -235,7 +263,7 @@
    // 报表期下拉分组:年报按年份;月报/季报按年分组列 1-12 月(能耗只列季度末月)
    periodGroups() {
      const year = new Date().getFullYear()
      const years = [year - 1, year, year + 1]
      const years = [year, year - 1, year + 1]
      if (this.cycle === 'year') {
        return [{ label: '选择年份', options: years.map(y => ({ label: y + '年', value: String(y) })) }]
      }
@@ -253,8 +281,8 @@
    }
  },
  created() {
    const mine = this.myModules.filter(k => this.modules.some(m => m.key === k))
    this.activeKey = (mine.length ? mine : this.modules.map(m => m.key))[0]
    const mine = this.myModules.filter(k => this.visibleModules.some(m => m.key === k))
    this.activeKey = (mine.length ? mine : this.visibleModules.map(m => m.key))[0]
    this.ensureCycle()
    this.refreshAll()
  },  methods: {
@@ -263,7 +291,7 @@
      return m ? m.title : key
    },
    logout() {
      localStorage.removeItem('token'); localStorage.removeItem('username'); localStorage.removeItem('realName'); localStorage.removeItem('modules')
      sessionStorage.removeItem('token'); sessionStorage.removeItem('username'); sessionStorage.removeItem('realName'); sessionStorage.removeItem('modules')
      this.$router.push('/login')
    },
    // ==================== 查询条件与状态刷新 ====================
@@ -303,7 +331,7 @@
      const m = this.curModule
      if (!m || !this.period) return
      const params = Object.assign(this.buildParams(), { types: m.items.map(i => i.key).join(',') })
      api.get('/report/ready', { params })
      api.get('/report/ready', { params, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          list.forEach(r => {
@@ -348,7 +376,7 @@
      if (!items.length) { this.$message.warning('请选择要预览的报表'); return }
      this.loading = 'preview-' + m.key
      const params = Object.assign(this.buildParams(), { types: items.map(i => i.key).join(',') })
      api.get('/report/preview', { params })
      api.get('/report/preview', { params, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          this.previewItems = list.map(it => {
@@ -370,6 +398,65 @@
      }
      return String(v)
    },
    wbRows(summary) {
      const get = (key) => {
        const hit = (summary || []).find(x => x.label === key)
        return hit && hit.value !== null && hit.value !== undefined && hit.value !== '' ? hit.value : null
      }
      const pct = (key) => {
        const v = get(key)
        return v === null ? null : this.fmtVal(v) + '%'
      }
      const mk = (defs) => defs.map(d => ({
        label: d[0],
        value: get(d[1]),
        yoy: d[2] ? pct(d[2]) : null
      }))
      return [
        {
          name: '道路货运',
          rows: mk([
            ['当月货运量(万吨)', '全省货运量·当月'],
            ['当月货物周转量(万吨)', '全省货物周转量·当月', '全省货物周转量·当月同比'],
            ['累计货运量(万吨)', '全省货运量·累计'],
            ['累计货物周转量(万吨公里)', '全省货物周转量·累计', '全省货物周转量·累计同比']
          ])
        },
        {
          name: '城市客运',
          rows: mk([
            ['当月城市客运量:', '城市客运量·当月'],
            ['当月城市客运周转量', '城市客运周转量·当月'],
            ['累计城市客运量:', '城市客运量·累计'],
            ['累计城市客运周转量', '城市客运周转量·累计']
          ])
        },
        {
          name: '中口径',
          rows: mk([
            ['当月中口径客运量', '中口径客运量·当月'],
            ['当月中口径周转量', '中口径周转量·当月'],
            ['累计中口径客运量', '中口径客运量·累计'],
            ['累计中口径周转量', '中口径周转量·累计']
          ])
        }
      ]
    },
    wbMissing(summary) {
      let total = 0
      let have = 0
      ;(summary || []).forEach(x => {
        if (String(x.label).indexOf('同比') >= 0) return
        total++
        if (x.value !== null && x.value !== undefined && x.value !== '') have++
      })
      return total > 0 && have < total
    },
    wbPrevYear() {
      const m = /^(\d{4})-(\d{2})$/.exec(this.previewPeriod || '')
      if (!m) return '上年同期'
      return (Number(m[1]) - 1) + '-' + m[2]
    },
    coverageText(it) {
      const cov = it.monthCoverage || []
      if (!cov.length) return '无数据'
@@ -386,18 +473,18 @@
      const params = this.buildParams()
      const doExport = () => {
        if (items.length === 1) {
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob' })
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, items[0].fileName); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
          return
        }
        const zipParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') })
        api.get('/report/export/selected', { params: zipParams, responseType: 'blob' })
        api.get('/report/export/selected', { params: zipParams, responseType: 'blob', timeout: 120000 })
          .then(res => { this.downloadBlob(res, '生成_所选报表_' + this.period + '.zip'); this.$message.success('已导出 ' + items.length + ' 张报表(zip)'); this.loading = '' })
          .catch(err => this.failWith(err))
      }
      const readyParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') })
      api.get('/report/ready', { params: readyParams })
      api.get('/report/ready', { params: readyParams, timeout: 60000 })
        .then(rd => {
          const list = (rd && rd.data && rd.data.items) || []
          const missing = list.filter(i => !i.ready)
@@ -419,6 +506,7 @@
        .catch(() => doExport())
    },
    failWith(err) {
      if (err && err._toast) { this.loading = ''; return }
      let msg = ''
      const d = err && err.response && err.response.data
      if (d && typeof d === 'object' && d.message) msg = d.message
@@ -483,4 +571,11 @@
@media (max-width: 900px) {
  .panel-head { flex-direction: column; align-items: flex-start; }
}
.wb-table th { background:#f5f7fa; }
.wb-sec { background:#F7F9FC; font-weight:600; text-align:center; color:#303133; }
.wb-label { color:#303133; }
.wb-value { text-align:right; font-variant-numeric: tabular-nums; }
.wb-yoy { text-align:right; color:#606266; }
.wb-yoy.yoy-empty { color:#C0C4CC; }
.wb-note { color:#E6A23C; font-size:12px; margin:2px 0 8px; }
</style>