zhizhijie
2026-09-21 a5e320a0df816063f519bb653a2821ad46399a29
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="/report">
        <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>
@@ -16,67 +16,752 @@
      </el-aside>
      <el-main>
        <div class="page-scroll">
        <h3>生成统计报表</h3>
        <el-card style="max-width:640px">
          <el-form label-width="100px">
            <el-form-item label="报表期">
              <el-date-picker v-model="period" type="month" placeholder="选择月份" value-format="yyyy-MM" style="width:100%"></el-date-picker>
            </el-form-item>
            <el-form-item>
              <el-button type="primary" @click="exportReport('cityDetail')" :loading="loading === 'cityDetail'">生成_货运量分市州明细</el-button>
            </el-form-item>
            <el-form-item>
              <el-button type="primary" @click="exportReport('freightRank')" :loading="loading === 'freightRank'">生成_货运量排名</el-button>
              <el-button type="primary" @click="exportReport('turnoverRank')" :loading="loading === 'turnoverRank'">生成_周转量排名</el-button>
            </el-form-item>
          </el-form>
        </el-card>
        <el-alert
          title="提示:报表由系统根据已导入数据自动计算生成。货运量:合计来自模板_货运量周转量(左半1-N月)、规上来自H203-2月报、规下=合计-规上;周转量来自规上规下拆分表。"
          type="info" style="margin-top:15px" :closable="false"></el-alert>
          <!-- 第一行:标题 + 更新时间 -->
          <div class="topbar">
            <h3>生成统计报表</h3>
            <div class="topbar-right">
              <span v-if="myModules.length" class="focus-user">{{ realName || username }}:负责 {{ myModules.map(moduleTitle).join('、') }}</span>
              <span class="update-time">更新时间:{{ updatedAt }}</span>
            </div>
          </div>
          <!-- 第二行:报表类型切换(货运 / 旅客 / 投资 / 能耗 / 城市客运);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">
            <span class="ctrl-label">周期</span>
            <el-radio-group v-model="cycle" size="small" @change="onCycleChange">
              <el-radio-button v-for="opt in cycleOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-radio-button>
            </el-radio-group>
            <span class="ctrl-label">{{ cycle === 'year' ? '年份' : '月份' }}</span>
            <el-select v-model="period" size="small" style="width:190px" :placeholder="periodPlaceholder" @change="onPeriodChange">
              <el-option-group v-for="grp in periodGroups" :key="grp.label" :label="grp.label">
                <el-option v-for="opt in grp.options" :key="opt.value" :label="opt.label" :value="opt.value"></el-option>
              </el-option-group>
            </el-select>
            <el-button type="primary" size="small" icon="el-icon-search" @click="doQuery">查询</el-button>
          </section>
          <!-- 第四行:报表列表 -->
          <section class="module-panel">
            <div class="panel-head">
              <div class="panel-title">报表列表<span class="muted">({{ curModule.title }})</span></div>
              <div class="panel-controls">
                <el-checkbox :value="allChecked" @change="toggleAll">全选</el-checkbox>
                <span class="checked-count">已选 {{ curModule.checked.length }} 张</span>
                <el-button size="small" icon="el-icon-view" :disabled="!curModule.checked.length" @click="previewChecked()">批量预览</el-button>
                <el-button size="small" type="primary" icon="el-icon-download" :disabled="!curModule.checked.length" @click="exportChecked()">批量导出</el-button>
              </div>
            </div>
            <el-table :data="curModule.items" size="mini" class="report-table">
              <el-table-column width="44">
                <template slot-scope="{ row }">
                  <el-checkbox :value="curModule.checked.indexOf(row.key) >= 0" @change="toggleChecked(row)"></el-checkbox>
                </template>
              </el-table-column>
              <el-table-column label="报表名称" prop="label" min-width="220"></el-table-column>
              <el-table-column label="数据状态" width="180">
                <template slot-scope="{ row }">
                  <el-tag v-if="row.status === 'ready'" size="mini" type="success">数据就绪</el-tag>
                  <el-tag v-else-if="row.status === 'unaudited'" size="mini" type="warning">未审核</el-tag>
                  <el-tag v-else-if="row.status === 'missing'" size="mini" type="danger">缺数据</el-tag>
                  <el-tag v-else size="mini" type="info">—</el-tag>
                  <div v-if="row.statusNote" class="status-note">{{ row.statusNote }}</div>
                </template>
              </el-table-column>
              <el-table-column label="备注" min-width="190">
                <template slot-scope="{ row }">
                  <span class="note-text">{{ noteOf(row.key) || '点击图标添加备注' }}</span>
                  <el-button size="mini" type="text" icon="el-icon-edit" title="编辑备注" @click="openNoteDialog(row)"></el-button>
                </template>
              </el-table-column>
              <el-table-column label="操作" width="170" align="right">
                <template slot-scope="{ row }">
                  <el-button size="mini" icon="el-icon-view" @click="previewOne(row)">预览</el-button>
                  <el-button size="mini" type="primary" icon="el-icon-download" @click="exportOne(row)">导出</el-button>
                </template>
              </el-table-column>
            </el-table>
            <div class="panel-footer">共 {{ curModule.items.length }} 张报表</div>
          </section>
          <el-alert title="使用提示" type="info" style="margin-top:15px" :closable="false">
            <div class="tip-text">
              <p>1. 选择报表类型 Tab、周期与月份后点击「查询」,列表会显示每张报表的数据状态(数据就绪 / 缺数据 / 未审核)。</p>
              <p>2. 数据校验与审核请到「审核结果」页查看,规则可在「规则管理」页配置。</p>
              <p>3. 单张可直接点「预览 / 导出」;多张可勾选后「批量预览 / 批量导出」,多张导出自动打包为 zip。</p>
              <p>4. 月报:选 2026-07 生成 1~7 月数据;年报:选年份生成全年 1~12 月 + 上年同比。</p>
            </div>
          </el-alert>
        </div>
        <el-dialog :title="'预览数据 - ' + previewModule + '(' + previewPeriod + ')'" :visible.sync="previewVisible" width="760px"
                   top="5vh" custom-class="pv-dialog" v-dialog-drag>
          <div class="preview-body">
            <div v-for="it in previewItems" :key="it.key" class="preview-item">
              <div class="preview-head">
                <span class="preview-name">{{ it.label }}</span>
                <span class="preview-rows" v-if="it.key === 'summaryWorkbook'">指标 {{ (it.summary || []).length }} 项</span>
                <span class="preview-rows" v-else>共 {{ it.rowCount || 0 }} 行明细</span>
              </div>
              <div class="preview-coverage">
                <span class="cov-label">月度覆盖:</span>
                <span :class="it.ready ? 'cov-ok' : 'cov-bad'">{{ coverageText(it) }}</span>
                <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 wb-table" v-if="it.key === 'summaryWorkbook' && it.summary && it.summary.length">
                <thead>
                  <tr><th style="width:12%">分类</th><th style="width:40%">指标</th><th style="width:28%">数据</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-if="it.key !== 'summaryWorkbook' && it.summary && it.summary.length">
                <tbody>
                  <tr v-for="(s, i) in it.summary" :key="i">
                    <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>
              <div class="preview-problems" v-if="it.problems && it.problems.length">
                <div v-for="(p, i) in it.problems" :key="i" class="prob-item">· {{ p }}</div>
              </div>
              <div class="preview-ok" v-else-if="it.ready">数据完整,可正常生成</div>
            </div>
          </div>
          <span slot="footer">
            <el-button size="small" @click="previewVisible = false">关闭</el-button>
          </span>
        </el-dialog>
        <!-- 备注编辑弹窗 -->
        <el-dialog title="编辑备注" :visible.sync="noteDialogVisible" width="480px">
          <el-input v-model="noteDraft" type="textarea" :rows="4" placeholder="填写该报表的备注说明(保存后本地记忆)"></el-input>
          <span slot="footer">
            <el-button size="small" @click="noteDialogVisible = false">取消</el-button>
            <el-button size="small" type="primary" @click="saveNote">保存</el-button>
          </span>
        </el-dialog>
        <!-- 汇总大表异步生成进度(生成一次约 10~20 秒,避免点击后长时间无响应) -->
        <el-dialog title="正在生成汇总大表" :visible.sync="taskVisible" width="460px"
                   :close-on-click-modal="false" :show-close="taskFinished">
          <div class="task-body">
            <el-progress :percentage="taskInfo.percent || 0" :status="taskInfo.status === 'FAILED' ? 'exception' : (taskDone ? 'success' : null)"
                         :stroke-width="14"></el-progress>
            <p class="task-step">{{ taskInfo.step || '排队中' }}</p>
            <p class="task-meta" v-if="taskInfo.period">报表期 {{ taskInfo.period }} {{ taskInfo.fromCache ? '上次生成用时' : '已用' }} {{ (taskInfo.elapsedMs / 1000).toFixed(1) }} 秒</p>
            <p class="task-meta" v-if="taskInfo.fromCache">本次直接使用 {{ taskCacheTime }} 生成的结果(10 分钟内且期间没有新导入数据)。如需按最新数据重算,请点「重新生成」。</p>
            <p class="task-error" v-if="taskInfo.status === 'FAILED'">{{ taskInfo.error }}</p>
          </div>
          <span slot="footer">
            <el-button size="small" type="primary" :disabled="!taskDone" @click="downloadTaskFile">{{ taskInfo.fromCache ? '下载' : '保存文件' }}</el-button>
            <el-button size="small" :disabled="taskRunning" @click="restartTask">重新生成</el-button>
            <el-button size="small" v-if="taskRunning" @click="minimizeTask">后台运行</el-button>
            <el-button size="small" v-else @click="closeTask">关闭</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'ReportExport',
  data() {
    return { period: '', loading: '' }
    return {
      loading: '',
      activeKey: '',
      updatedAt: '',
      // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy)
      cycle: 'month',
      period: '',
      // 预览弹窗
      previewVisible: false,
      previewItems: [],
      previewModule: '',
      previewPeriod: '',
      // 备注编辑弹窗
      noteDialogVisible: false,
      noteEditingKey: '',
      noteDraft: '',
      // 汇总大表异步生成任务
      taskVisible: false,
      taskInfo: {},
      taskTimer: null,
      taskKey: 'summaryWorkbook',
      modules: [
        {
          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: '旅客报表', 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: '城市客运报表', 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: 'wycSplit', label: '网约车客运量拆分表', fileName: '生成_网约车客运量拆分表.xlsx', status: null, statusNote: '' }
          ], checked: []
        },
        {
          key: 'invest', title: '投资报表', items: [
            { key: 'investPlan', label: '全省预安排计划进度汇总', fileName: '生成_全省预安排计划进度情况汇总.xls', status: null, statusNote: '' },
            { key: 'investFiveYearLogistics', label: '十五五规划物流项目进展', fileName: '生成_十五五规划物流项目进展情况.xls', status: null, statusNote: '' },
            { key: 'investBillion', label: '亿元投资项目', fileName: '生成_湖北省(客货站场)亿元投资项目.xls', status: null, statusNote: '' },
            { key: 'investCounty', label: '经济强县项目投资统计', fileName: '生成_经济强县交通物流基础设施投资统计报表.xls', status: null, statusNote: '' },
            { key: 'investPassengerDetail', label: '客运站投资明细表', fileName: '生成_X月客运站投资明细表.xlsx', status: null, statusNote: '' },
            { key: 'investLogisticsDetail', label: '物流站场投资明细', fileName: '生成_X月物流站场投资明细.xls', status: null, statusNote: '' }
          ], checked: []
        },
        {
          key: 'energy', title: '能耗报表', items: [
            { key: 'energySummary', label: '能运汇总表', fileName: '生成_能运汇总表.xlsx', status: null, statusNote: '' }
          ], checked: []
        }
      ]
    }
  },
  computed: {
    username() { return sessionStorage.getItem('username') || '' },
    realName() { return sessionStorage.getItem('realName') || '' },
    myModules() {
      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]
    },
    allChecked() {
      const m = this.curModule
      return m.items.length > 0 && m.checked.length === m.items.length
    },
    // 周期选项:能耗为季报(季度末月),其余为月报 / 年报
    cycleOptions() {
      if (this.activeKey === 'energy') return [{ label: '季报', value: 'quarter' }]
      return [{ label: '月报', value: 'month' }, { label: '年报', value: 'year' }]
    },
    periodPlaceholder() {
      return this.cycle === 'year' ? '选择年份' : '选择月份'
    },
    // 报表期下拉分组:年报按年份;月报/季报按年分组列 1-12 月(能耗只列季度末月)
    periodGroups() {
      const year = new Date().getFullYear()
      const years = [year, year - 1, year + 1]
      if (this.cycle === 'year') {
        return [{ label: '选择年份', options: years.map(y => ({ label: y + '年', value: String(y) })) }]
      }
      const months = this.activeKey === 'energy' ? [3, 6, 9, 12] : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
      return years.map(y => ({
        label: y + '年' + (this.activeKey === 'energy' ? '(季度末月)' : ''),
        options: months.map(mo => ({ label: y + '年' + mo + '月', value: y + '-' + String(mo).padStart(2, '0') }))
      }))
    },
    // ===== 以下为汇总大表异步生成弹窗的派生状态(必须并入同一个 computed,另起同名 key 会覆盖掉上面的计算属性)=====
    taskRunning() {
      const s = this.taskInfo && this.taskInfo.status
      return s === 'PENDING' || s === 'RUNNING'
    },
    taskDone() {
      return this.taskInfo && this.taskInfo.status === 'DONE'
    },
    // 生成结束(成功或失败)后才允许收起弹窗
    taskFinished() {
      const s = this.taskInfo && this.taskInfo.status
      return s === 'DONE' || s === 'FAILED'
    },
    taskCacheTime() {
      const t = this.taskInfo && this.taskInfo.finishedAt
      if (!t) return '上次'
      const d = new Date(t)
      const p = (n) => String(n).padStart(2, '0')
      return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
    }
  },
  watch: {
    activeKey() {
      this.ensureCycle()
      this.refreshAll()
    }
  },
  created() {
    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()
  },
  beforeDestroy() {
    this.stopTaskPolling()
  },
  methods: {
    exportReport(type) {
      if (!this.period) { this.$message.warning('请选择报表期'); return }
      this.loading = type
      const token = localStorage.getItem('token') || ''
      api.get('/report/export/' + type, { params: { period: this.period }, responseType: 'blob' })
        .then(res => {
          const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
          const url = window.URL.createObjectURL(blob)
          const a = document.createElement('a')
          a.href = url
          a.download = this.fileName(type)
          document.body.appendChild(a)
          a.click()
          window.URL.revokeObjectURL(url)
          this.$message.success('报表生成成功')
        })
        .catch(() => { this.$message.error('报表生成失败,请确认已导入对应数据') })
        .finally(() => { this.loading = '' })
    moduleTitle(key) {
      const m = this.modules.find(x => x.key === key)
      return m ? m.title : key
    },
    fileName(type) {
      const map = {
        cityDetail: '生成_货运量分市州明细.xlsx',
        freightRank: '生成_货运量排名.xlsx',
        turnoverRank: '生成_周转量排名.xlsx'
    logout() {
      sessionStorage.removeItem('token'); sessionStorage.removeItem('username'); sessionStorage.removeItem('realName'); sessionStorage.removeItem('modules')
      this.$router.push('/login')
    },
    // ==================== 查询条件与状态刷新 ====================
    buildParams() {
      const p = { period: this.period }
      p.mode = this.cycle === 'year' ? 'year' : 'month'
      return p
    },
    // 周期与报表期匹配:切换周期后清掉不匹配的旧值
    normalizePeriod() {
      const isYear = /^\d{4}$/.test(this.period || '')
      const isMonth = /^\d{4}-\d{2}$/.test(this.period || '')
      if (this.cycle === 'year' && isMonth) this.period = ''
      if (this.cycle !== 'year' && isYear) this.period = ''
    },
    ensureCycle() {
      const allowed = this.cycleOptions.map(o => o.value)
      if (!allowed.includes(this.cycle)) this.cycle = allowed[0]
      this.normalizePeriod()
    },
    onCycleChange() {
      this.normalizePeriod()
      this.refreshAll()
    },
    onPeriodChange() {
      this.refreshAll()
    },
    doQuery() {
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      this.refreshAll()
    },
    refreshAll() {
      this.updatedAt = new Date().toLocaleString('zh-CN', { hour12: false })
      this.refreshStatus()
    },
    refreshStatus() {
      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, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          list.forEach(r => {
            const it = m.items.find(x => x.key === r.key)
            if (!it) return
            if (!r.ready) { it.status = 'missing'; it.statusNote = r.note || '缺数据' }
            else if (r.audited === false) { it.status = 'unaudited'; it.statusNote = '尚未审核通过' }
            else { it.status = 'ready'; it.statusNote = '' }
          })
        })
        .catch(() => {})
    },
    noteOf(key) {
      return localStorage.getItem('report_note_' + key) || ''
    },
    openNoteDialog(row) {
      this.noteEditingKey = row.key
      this.noteDraft = this.noteOf(row.key)
      this.noteDialogVisible = true
    },
    saveNote() {
      localStorage.setItem('report_note_' + this.noteEditingKey, this.noteDraft.trim())
      this.noteDialogVisible = false
      this.$message.success('备注已保存')
    },
    // ==================== 勾选 / 预览 / 导出 ====================
    toggleChecked(row) {
      const checked = this.curModule.checked
      const i = checked.indexOf(row.key)
      if (i >= 0) checked.splice(i, 1)
      else checked.push(row.key)
    },
    toggleAll(v) {
      this.curModule.checked = v ? this.curModule.items.map(i => i.key) : []
    },
    exportOne(item) { this.exportChecked([item]) },
    previewOne(item) { this.previewChecked([item]) },
    previewChecked(itemList) {
      const m = this.curModule
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      const items = itemList || m.items.filter(i => m.checked.includes(i.key))
      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, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          this.previewItems = list.map(it => {
            const item = items.find(x => x.key === it.key)
            return Object.assign({}, it, { label: item ? item.label : it.key })
          })
          this.previewModule = m.title
          this.previewPeriod = this.period
          this.previewVisible = true
          this.loading = ''
        })
        .catch(err => this.failWith(err, '预览'))
    },
    fmtVal(v) {
      if (v === null || v === undefined || v === '') return '—'
      if (typeof v === 'number') {
        if (Number.isInteger(v)) return String(v)
        return v.toFixed(4).replace(/\.?0+$/, '')
      }
      return map[type] || 'report.xlsx'
      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 '无数据'
      const continuous = cov.length === cov[cov.length - 1]
      if (continuous) return '1~' + cov[cov.length - 1] + '月均有数据'
      return cov.map(x => x + '月').join('、') + ' 有数据'
    },
    // ==================== 汇总大表异步生成(进度弹窗) ====================
    // 后端 ReportExportTaskService:提交任务 -> 轮询进度 -> 完成后下载文件。
    // 同报表期 10 分钟内且期间没有新导入数据时,后端直接返回上次结果(弹窗内会说明来源时间)。
    startSummaryTask(refresh) {
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      this.stopTaskPolling()
      this.taskInfo = { status: 'PENDING', percent: 0, step: '提交任务', period: this.period, elapsedMs: 0 }
      this.taskVisible = true
      this.loading = 'gen-' + this.curModule.key
      const params = Object.assign({}, this.buildParams(), { refresh: refresh ? 'true' : 'false' })
      api.get('/report/exportTask/summaryWorkbook', { params, timeout: 60000 })
        .then(res => {
          // 后端统一返回 Result:code 非 200 时 data 为空,必须提示而不是空转
          if (!res || res.code !== 200 || !res.data || !res.data.taskId) {
            this.taskInfo = { status: 'FAILED', percent: 0, step: '提交失败', period: this.period, elapsedMs: 0,
                              error: (res && res.message) || '后端未返回任务号' }
            this.loading = ''
            return
          }
          this.taskInfo = res.data
          if (this.taskInfo.status === 'DONE') { this.loading = ''; this.downloadTaskFile(); return }
          if (this.taskInfo.status === 'FAILED') { this.loading = ''; return }
          this.pollTask()
        })
        .catch(err => {
          this.stopTaskPolling()
          this.loading = ''
          this.taskVisible = false
          this.failWith(err, '生成')
        })
    },
    pollTask() {
      this.stopTaskPolling()
      const id = this.taskInfo && this.taskInfo.taskId
      if (!id) return
      this.taskTimer = setInterval(() => {
        api.get('/report/exportTask/' + id, { timeout: 30000 })
          .then(res => {
            if (!res || res.code !== 200 || !res.data) {
              // 任务被清理/过期:停止轮询并给出原因,不让弹窗一直空转
              this.stopTaskPolling()
              this.loading = ''
              this.taskInfo = Object.assign({}, this.taskInfo, {
                status: 'FAILED', step: '任务已失效',
                error: (res && res.message) || '导出任务已失效,请重新生成'
              })
              return
            }
            this.taskInfo = res.data
            if (this.taskInfo.status === 'DONE') {
              this.stopTaskPolling()
              this.loading = ''
              this.downloadTaskFile()
            } else if (this.taskInfo.status === 'FAILED') {
              this.stopTaskPolling()
              this.loading = ''
              if (!this.taskVisible) this.$message.error('汇总大表生成失败:' + (this.taskInfo.error || '未知原因'))
            }
          })
          .catch(() => { /* 单次轮询失败忽略,下一轮继续 */ })
      }, 700)
    },
    stopTaskPolling() {
      if (this.taskTimer) { clearInterval(this.taskTimer); this.taskTimer = null }
    },
    downloadTaskFile() {
      const id = this.taskInfo && this.taskInfo.taskId
      if (!id) return
      const name = this.taskInfo.fileName || ('生成_道路运输量汇总表_' + this.period + '.xlsx')
      api.get('/report/exportTask/' + id + '/file', { responseType: 'blob', timeout: 120000 })
        .then(res => { this.downloadBlob(res, name); this.$message.success('报表导出成功') })
        .catch(err => this.failWith(err))
    },
    restartTask() { this.startSummaryTask(true) },
    closeTask() { this.taskVisible = false; this.stopTaskPolling(); this.loading = '' },
    // 生成中收起弹窗:任务继续在后台执行,完成后仍会自动下载(与原来同步导出的「点完即下载」一致)
    minimizeTask() {
      this.taskVisible = false
      this.$message.info('已转入后台生成,完成后会自动下载')
    },
    exportChecked(itemList) {
      const m = this.curModule
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      const items = itemList || m.items.filter(i => m.checked.includes(i.key))
      if (!items.length) { this.$message.warning('请选择要导出的报表'); return }
      this.loading = 'gen-' + m.key
      const params = this.buildParams()
      const doExport = () => {
        if (items.length === 1) {
          // 汇总大表生成一次约 10~20 秒,走异步任务(进度弹窗),避免点击后长时间无响应
          if (items[0].key === 'summaryWorkbook') { this.startSummaryTask(false); return }
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); 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', 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, timeout: 60000 })
        .then(rd => {
          const list = (rd && rd.data && rd.data.items) || []
          const missing = list.filter(i => !i.ready)
          const unaudited = list.filter(i => i.ready && i.audited === false)
          const lines = []
          missing.forEach(i => {
            const it = items.find(x => x.key === i.key)
            lines.push('【缺数据】' + (it ? it.label : i.key) + '(' + i.note + ')')
          })
          unaudited.forEach(i => {
            const it = items.find(x => x.key === i.key)
            lines.push('【未审核】' + (it ? it.label : i.key))
          })
          if (!lines.length) { doExport(); return }
          this.$confirm('以下报表请注意:\n' + lines.join('\n') + '\n\n仍要导出吗?', '导出前提示', {
            confirmButtonText: '仍要导出', cancelButtonText: '取消', type: 'warning'
          }).then(() => doExport()).catch(() => { this.loading = '' })
        })
        .catch(() => doExport())
    },
    // 预览/导出失败统一提示:区分“连不上服务器”与“后端返回的错误原因”。
    // 导出接口用 responseType: 'blob',报错时 data 也是 Blob,必须先读文本再解析,
    // 否则拿不到后端真实原因,只会显示笼统的“请确认已导入对应数据”。
    failWith(err, action) {
      if (err && err._toast) { this.loading = ''; return }
      const act = action || '导出'
      const finish = (reason) => {
        this.$message.error(act + '失败:' + reason)
        this.loading = ''
      }
      if (!err || !err.response) {
        if (err && err.code === 'ECONNABORTED') { finish('请求超时,请稍后重试'); return }
        finish('无法连接服务器,请检查网络连接,或确认后端服务是否已启动')
        return
      }
      const d = err.response.data
      const fallback = '请确认已导入对应数据,或查看后端日志'
      if (d && typeof d.text === 'function') {
        Promise.resolve(d.text()).then(txt => finish(this.errReason(txt) || fallback)).catch(() => finish(fallback))
        return
      }
      finish(this.errReason(typeof d === 'string' ? d : (d && d.message)) || fallback)
    },
    // 后端异常响应可能是 JSON/HTML/纯文本,尽量提取出可读的原因
    errReason(raw) {
      if (!raw) return ''
      let text = String(raw).trim()
      if (text.charAt(0) === '{' || text.charAt(0) === '[') {
        try {
          const j = JSON.parse(text)
          if (j && j.message) return j.message
        } catch (e) { /* 非 JSON,按纯文本处理 */ }
      }
      text = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
      // 开发代理/网络层报错转成人话,避免直接把 ECONNREFUSED 之类抛给用户
      if (/ECONNREFUSED|ECONNRESET|Proxy error|socket hang up|Network Error|Failed to fetch/i.test(text)) {
        return '无法连接服务器,请检查网络连接,或确认后端服务是否已启动'
      }
      return text.length > 200 ? text.slice(0, 200) + '…' : text
    },
    // 文件名占位符 X月 -> 当前报表期月份(如「生成_X月客运站投资明细表.xlsx」->「生成_8月客运站投资明细表.xlsx」)
    fileNameOf(item) {
      const name = (item && item.fileName) || ''
      const mm = parseInt((this.period || '').split('-')[1], 10)
      if (!mm) return name
      return name.replace(/X月/g, mm + '月')
    },
    downloadBlob(res, fileName) {
      const isZip = /\.zip$/.test(fileName)
      const isXls = /\.xls$/.test(fileName)
      const blob = new Blob([res], { type: isZip ? 'application/zip' : (isXls ? 'application/vnd.ms-excel' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') })
      const url = window.URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = fileName
      document.body.appendChild(a)
      a.click()
      window.URL.revokeObjectURL(url)
    }
  }
}
</script>
<style scoped>
</script><style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
.page-scroll { max-width: 1200px; }
.topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.topbar h3 { margin: 0; }
.topbar-right { display: flex; align-items: center; gap: 12px; font-size: 13px; color: #606266; }
.update-time { font-size: 12px; color: #909399; }
.module-tabs { margin-bottom: 6px; }
.query-panel { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.data-card { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; }
.card-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.muted { font-size: 12px; color: #909399; font-weight: 400; }
.module-panel { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 14px 16px; }
.ctrl-label { font-size: 13px; color: #606266; }
.panel-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
.panel-title { font-size: 16px; font-weight: 600; color: #303133; display: flex; align-items: center; gap: 6px; }
.panel-controls { display: flex; align-items: center; gap: 10px; }
.report-table { width: 100%; }
.checked-count { font-size: 12px; color: #909399; }
.panel-footer { margin-top: 10px; font-size: 12px; color: #909399; text-align: right; }
.status-note { font-size: 11px; color: #F56C6C; margin-top: 2px; line-height: 1.4; }
.note-text { font-size: 12px; color: #606266; margin-right: 4px; }
.spacer { flex: 1; }
.preview-body { max-height: 68vh; overflow-y: auto; }
.preview-item { border: 1px solid #ebeef5; border-radius: 6px; padding: 12px 14px; margin-bottom: 12px; }
.preview-head { display: flex; align-items: center; margin-bottom: 8px; }
.preview-name { font-weight: 600; font-size: 15px; margin-right: 10px; }
.preview-rows { margin-left: auto; color: #909399; font-size: 12px; }
.preview-coverage { font-size: 13px; margin-bottom: 8px; }
.cov-ok { color: #67C23A; }
.cov-bad { color: #F56C6C; }
.cov-label { color: #909399; }
.preview-summary { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; }
.preview-summary th, .preview-summary td { border: 1px solid #ebeef5; padding: 4px 8px; text-align: left; }
.preview-summary th { background: #f5f7fa; }
.preview-summary .cell-empty { color: #F56C6C; }
.preview-problems { font-size: 13px; color: #F56C6C; }
.preview-problems .prob-item { margin: 2px 0; }
.preview-ok { font-size: 13px; color: #67C23A; }
.tip-text { font-size: 13px; line-height: 1.7; }
.tip-text p { margin: 2px 0; }
@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; }
/* 异步生成进度弹窗 */
.task-body { padding: 4px 2px; }
.task-step { margin: 12px 0 4px; font-size: 14px; color:#303133; }
.task-meta { margin: 2px 0; font-size: 12px; color:#909399; line-height: 1.6; }
.task-error { margin: 8px 0 0; font-size: 13px; color:#F56C6C; word-break: break-all; }
</style>
<style>
/* 预览数据弹窗:圆角 + 可通过标题栏拖动(v-dialog-drag 指令)。
   不能写进 scoped:el-dialog 默认 append-to-body,DOM 挂在 body 上,scoped 选择器匹配不到。 */
.pv-dialog { border-radius: 10px; overflow: hidden; }
.pv-dialog .el-dialog__header { cursor: move; user-select: none; -webkit-user-select: none; }
.pv-dialog .el-dialog__headerbtn { cursor: pointer; }
</style>