zhizhijie
2026-09-21 a5e320a0df816063f519bb653a2821ad46399a29
traffic-audit-web/src/views/ReportExport.vue
@@ -160,6 +160,25 @@
            <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>
@@ -189,6 +208,11 @@
      noteDialogVisible: false,
      noteEditingKey: '',
      noteDraft: '',
      // 汇总大表异步生成任务
      taskVisible: false,
      taskInfo: {},
      taskTimer: null,
      taskKey: 'summaryWorkbook',
      modules: [
        {
          key: 'summary', title: '汇总大表', items: [
@@ -277,6 +301,26 @@
        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() {
@@ -289,7 +333,11 @@
    this.activeKey = (mine.length ? mine : this.visibleModules.map(m => m.key))[0]
    this.ensureCycle()
    this.refreshAll()
  },  methods: {
  },
  beforeDestroy() {
    this.stopTaskPolling()
  },
  methods: {
    moduleTitle(key) {
      const m = this.modules.find(x => x.key === key)
      return m ? m.title : key
@@ -468,6 +516,86 @@
      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 }
@@ -477,6 +605,8 @@
      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))
@@ -621,6 +751,11 @@
.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>