zhizhijie
2026-09-20 a08c70db67e49bf6cd125df04ebaf68c26f0d8dd
traffic-audit-web/src/views/ReportExport.vue
@@ -27,7 +27,7 @@
          <!-- 第二行:报表类型切换(货运 / 旅客 / 投资 / 能耗 / 城市客运);el-radio-group 替代 el-tabs,规避 el-tabs 布局无限循环(与 /import 修复一致) -->
          <el-radio-group v-model="activeKey" class="module-tabs">
            <el-radio-button v-for="m in modules" :key="m.key" :label="m.key">
            <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>
@@ -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>
@@ -112,11 +114,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: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.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>
@@ -137,6 +158,25 @@
          <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>
@@ -168,29 +208,40 @@
      noteDialogVisible: false,
      noteEditingKey: '',
      noteDraft: '',
      // 汇总大表异步生成任务
      taskVisible: false,
      taskInfo: {},
      taskTimer: null,
      taskKey: 'summaryWorkbook',
      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: []
        },
        {
@@ -198,7 +249,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: []
        },
        {
@@ -216,6 +269,9 @@
      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 +291,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) })) }]
      }
@@ -245,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() {
@@ -253,11 +329,15 @@
    }
  },
  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: {
  },
  beforeDestroy() {
    this.stopTaskPolling()
  },
  methods: {
    moduleTitle(key) {
      const m = this.modules.find(x => x.key === key)
      return m ? m.title : key
@@ -360,7 +440,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 '—'
@@ -370,12 +450,151 @@
      }
      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
@@ -386,8 +605,10 @@
      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, 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
        }
@@ -418,13 +639,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)
@@ -484,4 +744,24 @@
@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>