zhizhijie
2026-09-19 ade4387f72c876f779b0956295922003464434f0
traffic-audit-web/src/views/ReportExport.vue
@@ -99,12 +99,14 @@
            </div>
          </el-alert>
        </div>
        <el-dialog :title="'预览数据 - ' + previewModule + '(' + previewPeriod + ')'" :visible.sync="previewVisible" width="760px">
        <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">共 {{ it.rowCount || 0 }} 行</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>
@@ -114,7 +116,7 @@
              </div>
              <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>
                  <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)">
@@ -223,7 +225,9 @@
            { 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: '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: []
        },
        {
@@ -388,7 +392,7 @@
          this.previewVisible = true
          this.loading = ''
        })
        .catch(err => this.failWith(err))
        .catch(err => this.failWith(err, '预览'))
    },
    fmtVal(v) {
      if (v === null || v === undefined || v === '') return '—'
@@ -474,7 +478,7 @@
      const doExport = () => {
        if (items.length === 1) {
          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 = '' })
            .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
          return
        }
@@ -505,13 +509,52 @@
        })
        .catch(() => doExport())
    },
    failWith(err) {
    // 预览/导出失败统一提示:区分“连不上服务器”与“后端返回的错误原因”。
    // 导出接口用 responseType: 'blob',报错时 data 也是 Blob,必须先读文本再解析,
    // 否则拿不到后端真实原因,只会显示笼统的“请确认已导入对应数据”。
    failWith(err, action) {
      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
      this.$message.error('导出失败:' + (msg || '请确认已导入对应数据,或查看后端日志'))
      this.loading = ''
      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)
@@ -579,3 +622,11 @@
.wb-yoy.yoy-empty { color:#C0C4CC; }
.wb-note { color:#E6A23C; font-size:12px; margin:2px 0 8px; }
</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>