traffic-audit-web/src/views/DataImport.vue
@@ -1,4 +1,4 @@
SKIP/CHECK (0): TYPE_LABELS upper <template>
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 数据导入"></app-header>
    <el-container>
@@ -37,7 +37,7 @@
                                <div class="module-sel-row">
                  <el-select v-model="moduleSel[g.key]" placeholder="选择数据类型" style="width:260px"
                             @change="onModuleChange(g.key)" clearable>
                    <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
                    <el-option v-for="t in typeList(g)" :key="t.value" :label="t.label" :value="t.value"></el-option>
                  </el-select>
                  <div v-if="importType" class="tpl-box">
                    <span class="tpl-name">模板:{{ templateName }}</span>
@@ -54,7 +54,7 @@
                <div v-else-if="moduleSel[g.key] && !isDirOnlyType(moduleSel[g.key])" class="module-file">
                  <div class="file-name" :title="fileNamesText(moduleSel[g.key])">{{ fileNamesText(moduleSel[g.key]) || '未选择文件' }}</div>
                  <div class="file-btns">
                    <el-upload action="#" :auto-upload="false" :show-file-list="false" multiple accept=".xlsx,.xls,.et"
                    <el-upload action="#" :auto-upload="false" :show-file-list="false" multiple :accept="acceptOf(moduleSel[g.key])"
                               :ref="'up' + g.key" :on-change="f => onFilePicked(g.key, f)" style="display:inline-block">
                      <el-button size="mini" type="primary" plain icon="el-icon-folder-opened" :disabled="importing">选择文件</el-button>
                    </el-upload>
@@ -114,7 +114,7 @@
                <div v-else class="overview-grid">
                  <div class="ov-row" v-for="g in visibleGroups" :key="g.key">
                    <div class="ov-group">{{ g.name }}</div>
                    <div class="ov-item" v-for="t in g.types" :key="t.value" :class="overviewState(t.value).cls">
                    <div class="ov-item" v-for="t in typeList(g)" :key="t.value" :class="overviewState(t.value).cls">
                      <div class="ov-name">{{ t.label }}</div>
                      <div class="ov-status">{{ overviewState(t.value).text }}</div>
                    </div>
@@ -134,7 +134,7 @@
            <div v-if="clearScope === 'types'" class="clear-types">
              <el-select v-model="clearTypes" multiple filterable collapse-tags placeholder="选择要清空的数据类型" style="width:100%">
                <el-option-group v-for="g in clearableGroups()" :key="g.key" :label="g.name">
                  <el-option v-for="t in g.types" :key="t.value" :value="t.value"
                  <el-option v-for="t in typeList(g)" :key="t.value" :value="t.value"
                             :label="t.label + '(' + overviewState(t.value).text + ')'"></el-option>
                </el-option-group>
              </el-select>
@@ -148,6 +148,19 @@
            <el-button @click="clearDialog = false">取消</el-button>
            <el-button type="danger" :loading="clearing"
                       :disabled="clearScope === 'types' && !clearTypes.length" @click="confirmClear">确认清空</el-button>
          </span>
        </el-dialog>
        <!-- 导入结果弹窗:可拖动、内容区滚动、底部按钮固定可见(原 $alert 内容长时底部按钮会被挤出屏幕) -->
        <el-dialog :title="resultDialog.title" :visible.sync="resultDialog.visible" width="860px" top="5vh"
                   append-to-body :close-on-click-modal="false" custom-class="import-result-dialog" v-dialog-drag>
          <div v-if="resultDialog.level" class="ird-level" :class="'ird-' + resultDialog.level">
            {{ resultDialog.level === 'error' ? '导入失败' : (resultDialog.level === 'warning' ? '存在需要确认的内容' : '导入成功') }}
          </div>
          <div class="ird-body" v-html="resultDialog.html"></div>
          <span slot="footer">
            <el-button v-if="resultDialog.rows.length" size="small" icon="el-icon-download"
                       @click="exportResultDialog">导出明细</el-button>
            <el-button type="primary" @click="resultDialog.visible = false">确认</el-button>
          </span>
        </el-dialog>
      </el-main>
@@ -165,11 +178,11 @@
  investCityPassenger: '市州单表(客运站场)', investCityLogistics: '市州单表(物流园区)',
  'INVEST_客运站场_CITY': '市州单表(客运站场)', 'INVEST_物流园区_CITY': '市州单表(物流园区)',
  cityBus: '城市公交月报', cityTaxi: '巡游出租月报', cityTaxiAuth: '出租车运政车辆',
  wycTotal: '网约车总数', wycOrder: '网约车订单及全省总量',
  wycTotal: '网约车总数', wycOrder: '网约车订单及全省总量', wycOrderReport: '网约车订单报告',
  H2032: 'H203-2月报', H2031: 'H203-1旅客月报', H204: 'H204能耗季报', PASSENGER_AUTH: '旅客运政车辆数',
  PASSENGER_INDIVIDUAL: '个体客运量/周转量', ENERGY_AUTH: '能耗车辆运政信息', TRANSPORT_AUTH: '运政车辆',
  TRACK_MILEAGE: '轨迹里程', SCALE_SPLIT: '规上规下拆分', FREIGHT_TURNOVER: '货运量周转量',
  INVEST_SYSTEM: '投资系统导出', CITY_BUS: '城市公交月报', CITY_TAXI: '巡游出租月报', CITY_TAXI_AUTH: '出租车运政车辆', WYC_TOTAL: '网约车总数', WYC_ORDER: '网约车订单及全省总量'
  INVEST_SYSTEM: '投资系统导出', CITY_BUS: '城市公交月报', CITY_TAXI: '巡游出租月报', CITY_TAXI_AUTH: '出租车运政车辆', WYC_TOTAL: '网约车总数', WYC_ORDER: '网约车订单及全省总量', WYC_ORDER_REPORT: '网约车订单报告'
}
const BATCH_TYPE_MAP = {
  H2032: 'h2032', TRANSPORT_AUTH: 'transportAuth', TRACK_MILEAGE: 'trackMileage',
@@ -177,7 +190,7 @@
  H2031: 'passengerMonthly', PASSENGER_AUTH: 'passengerAuth', PASSENGER_INDIVIDUAL: 'passengerIndividual',
  H204: 'energyMonthly', ENERGY_AUTH: 'energyAuth',
  CITY_BUS: 'cityBus', CITY_TAXI: 'cityTaxi', CITY_TAXI_AUTH: 'cityTaxiAuth',
  WYC_TOTAL: 'wycTotal', WYC_ORDER: 'wycOrder',
  WYC_TOTAL: 'wycTotal', WYC_ORDER: 'wycOrder', WYC_ORDER_REPORT: 'wycOrderReport',
  INVEST_SYSTEM: 'investmentSystem'
}
export default {
@@ -193,6 +206,7 @@
      clearDialog: false,
      clearScope: 'all',
      clearTypes: [],
      resultDialog: { visible: false, title: '', html: '', rows: [], columns: [], fileName: '', level: '' },
      sideOpen: false,
      result: '',
      error: '',
@@ -226,7 +240,8 @@
            { value: 'cityTaxi', label: '巡游出租汽车运营服务情况月报' },
            { value: 'cityTaxiAuth', label: '出租车运政车辆' },
            { value: 'wycTotal', label: '网约车总数' },
            { value: 'wycOrder', label: '网约车订单及全省总量' }
            { value: 'wycOrder', label: '网约车订单及全省总量', hidden: true },
            { value: 'wycOrderReport', label: '网约车订单报告' }
          ]
        },
        {
@@ -299,7 +314,8 @@
        cityTaxi: '导入_巡游出租汽车运营服务情况月报(YYYY年M月).xls(市州级17行)',
        cityTaxiAuth: '导入模板_出租车信息(运政车辆信息).xlsx(车辆级明细,用于运营车数对比)',
        wycTotal: '模板_网约车总数.xlsx(A列=月份,B-E=全省客运量/城市内/周转量/城市内周转量)',
        wycOrder: '模板_网约车订单及全省总量.xlsx(A列=月份,B-E=全省pin,F=订单合计,G-W=17市州订单)'
        wycOrder: '模板_网约车订单及全省总量.xlsx(A列=月份,B-E=全省pin,F=订单合计,G-W=17市州订单)',
        wycOrderReport: '模板_网约车订单.docx(《网约车行业YYYY年M月运行监测情况分析报告》,自动解析附件表「地市名称…订单数总数」的 17 市州订单)'
      }
      return map[this.importType] || ''
    },
@@ -320,6 +336,7 @@
        cityTaxiAuth: '城市客运/导入模板_出租车信息(运政车辆信息).xlsx',
        wycTotal: '城市客运/模板_网约车总数.xlsx',
        wycOrder: '城市客运/模板_网约车订单及全省总量.xlsx',
        wycOrderReport: '城市客运/模板_网约车订单.docx',
        investment: '投资/输入/客运(客运站)/更新_7月客运站投资明细表(市州汇总大表,给领导).xlsx',
        investmentLogistics: '投资/输入/物流(物流园区)/更新_7月物流站场投资明细.xls',
        investmentSystem: '投资/模板_查询结果(投资系统-客运+物流 2026.7).xls'
@@ -331,7 +348,20 @@
    typeLabel(t) { return TYPE_LABELS[t] || t },
    downloadTemplate() {
      if (!this.templateUrl) return
      window.open('/api/templates/' + this.templateUrl)
      const name = this.templateUrl.split('/').pop()
      api.get('/templates/' + this.templateUrl, { responseType: 'blob' })
        .then(res => {
          const blob = res instanceof Blob ? res : new Blob([res])
          const url = window.URL.createObjectURL(blob)
          const a = document.createElement('a')
          a.href = url
          a.download = name
          document.body.appendChild(a)
          a.click()
          window.URL.revokeObjectURL(url)
          document.body.removeChild(a)
        })
        .catch(() => this.$message.error('模板下载失败,请稍后重试'))
    },
    onModuleChange(key) {
      this.importType = this.moduleSel[key] || ''
@@ -359,7 +389,8 @@
        cityTaxi: '/import/cityTaxi',
        cityTaxiAuth: '/import/cityTaxiAuth',
        wycTotal: '/import/wycTotal',
        wycOrder: '/import/wycOrder'
        wycOrder: '/import/wycOrder',
        wycOrderReport: '/import/wycOrderReport'
      }
      return map[t] || ''
    },
@@ -446,11 +477,15 @@
            (f.city ? '(' + f.city + ')' : '') + ':解析 ' + (f.success || 0) + ' 条' +
            (f.error ? ' <span style="color:#F56C6C">' + f.error + '</span>' : '') + '</div>'
          ).join('')
          this.$alert(html, (label || type) + ' 批量导入完成:共 ' + d.total + ' 个文件' +
            (d.fail > 0 ? ',失败 ' + d.fail + ' 条' : '') + '(成功 ' + d.success + ' 条)', {
            dangerouslyUseHTMLString: true,
            type: errCount > 0 ? 'warning' : 'success',
            confirmButtonText: '知道了'
          this.showResultDialog({
            title: (label || type) + ' 批量导入完成:共 ' + d.total + ' 个文件' +
              (d.fail > 0 ? ',失败 ' + d.fail + ' 条' : '') + '(成功 ' + d.success + ' 条)',
            html: html,
            level: errCount > 0 ? 'warning' : 'success',
            columns: [{ key: 'fileName', label: '文件名' }, { key: 'city', label: '市州' },
                      { key: 'success', label: '解析条数' }, { key: 'error', label: '错误' }],
            rows: files.map(f => ({ fileName: f.fileName || '', city: f.city || '', success: f.success || 0, error: f.error || '' })),
            fileName: (label || '批量导入') + '_' + this.period + '_导入结果'
          })
          if (type === 'investment' || type === 'investmentLogistics') {
            this.checkInvest(this.period)
@@ -464,6 +499,7 @@
          this.$message.error((e.response && e.response.data && e.response.data.message) || '批量导入失败')
        })
    },
    acceptOf(type) { return type === 'wycOrderReport' ? '.docx,.doc' : '.xlsx,.xls,.et' },
    isCityDirType(t) { return t === 'investCityPassenger' || t === 'investCityLogistics' },
    isDirOnlyType(t) { return t === 'investCityPassenger' || t === 'investCityLogistics' },
    importCityDir(type) {
@@ -487,12 +523,16 @@
            (f.city ? '(' + f.city + ')' : '') + ':解析 ' + (f.success || 0) + ' 个项目' +
            (f.error ? ' <span style="color:#F56C6C">' + f.error + '</span>' : '') + '</div>'
          ).join('')
          this.$alert(html, '市州单表批量导入完成:共 ' + d.total + ' 条,成功 ' + d.success + ' 条' +
            (d.fail > 0 ? ',失败 ' + d.fail + ' 条' : '') +
            '(十五五项目匹配 ' + d.fiveYearMatched + '/' + d.fiveYearTemplateProjects + ')', {
            dangerouslyUseHTMLString: true,
            type: errCount > 0 ? 'warning' : 'success',
            confirmButtonText: '知道了'
          this.showResultDialog({
            title: '市州单表批量导入完成:共 ' + d.total + ' 条,成功 ' + d.success + ' 条' +
              (d.fail > 0 ? ',失败 ' + d.fail + ' 条' : '') +
              '(十五五项目匹配 ' + d.fiveYearMatched + '/' + d.fiveYearTemplateProjects + ')',
            html: html,
            level: errCount > 0 ? 'warning' : 'success',
            columns: [{ key: 'fileName', label: '文件名' }, { key: 'city', label: '市州' },
                      { key: 'success', label: '解析项目数' }, { key: 'error', label: '错误' }],
            rows: files.map(f => ({ fileName: f.fileName || '', city: f.city || '', success: f.success || 0, error: f.error || '' })),
            fileName: '市州单表导入结果_' + this.period
          })
          this.afterImport()
        })
@@ -509,10 +549,10 @@
        const text = (msg && msg.indexOf('导入') !== -1) ? msg : '数据导入成功'
        this.$message({ message: text, type: 'success', duration: 5000 })
      } else {
        this.$alert((msg || '数据导入失败,详见下方失败记录').replace(/\n/g, '<br>'), '数据导入失败', {
          dangerouslyUseHTMLString: true,
          type: 'error',
          confirmButtonText: '知道了'
        this.showResultDialog({
          title: '数据导入失败',
          html: (msg || '数据导入失败,详见下方失败记录').replace(/\n/g, '<br>'),
          level: 'error'
        })
      }
    },
@@ -550,6 +590,7 @@
              map[key] = { totalRows: r.totalRows, successRows: r.successRows, failRows: r.failRows, latestId: r.id }
            }
          })
          if (map.wycOrderReport && !map.wycOrder) map.wycOrder = map.wycOrderReport
          this.overview = map
        })
        .catch(e => { if (!e || !e._toast) this.$message.error('导入记录加载失败,请刷新重试') })
@@ -564,6 +605,10 @@
    },
    clearableGroups() {
      return this.groups.filter(g => !g.hidden)
    },
    // 单个数据类型也可隐藏(hidden: true):入口、概览、清空下拉统一过滤
    typeList(g) {
      return (g && g.types ? g.types : []).filter(t => !t.hidden)
    },
    confirmClear() {
      if (!this.period) { this.$message.warning('请先选择报表期'); return }
@@ -623,14 +668,55 @@
                '<br/>实际:' + (x.actualValue || '') + ' 基准:' + (x.thresholdValue || '') +
                '<br/>差异:' + (x.deviation || '') + '</div>'
            }).join('')
            this.$alert(html, '投资数据与投资系统不一致(' + pending.length + ' 条)', {
              dangerouslyUseHTMLString: true, type: 'warning', confirmButtonText: '知道了'
            this.showResultDialog({
              title: '投资数据与投资系统不一致(' + pending.length + ' 条)',
              html: html,
              level: 'warning',
              columns: [{ key: 'rule', label: '规则' }, { key: 'name', label: '项目/企业' },
                        { key: 'actual', label: '实际值' }, { key: 'base', label: '基准值' }, { key: 'dev', label: '差异' }],
              rows: pending.map(x => ({
                rule: x.ruleName || '',
                name: x.enterpriseName || x.actualValue || '',
                actual: x.actualValue || '',
                base: x.thresholdValue || '',
                dev: x.deviation || ''
              })),
              fileName: '投资数据与投资系统不一致_' + this.period
            })
          } else {
            this.$message.success('已与投资系统核对,本月数据一致')
          }
        })
        .catch(() => {})
    },
  showResultDialog(opts) {
      const o = opts || {}
      this.resultDialog = {
        visible: true,
        title: o.title || '导入结果',
        html: o.html || '',
        level: o.level || '',
        rows: o.rows || [],
        columns: o.columns || [],
        fileName: o.fileName || ('导入结果_' + (this.period || ''))
      }
    },
    exportResultDialog() {
      const cols = this.resultDialog.columns || []
      const rows = this.resultDialog.rows || []
      if (!cols.length || !rows.length) { this.$message.warning('没有可导出的明细'); return }
      const esc = v => '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"'
      const lines = [cols.map(c => esc(c.label)).join(',')]
      rows.forEach(r => lines.push(cols.map(c => esc(r[c.key])).join(',')))
      const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = this.resultDialog.fileName + '.csv'
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
    },
  parseFailDetails(row) {
    if (!row || !row.errorDetail) return []
@@ -700,3 +786,17 @@
.module-tabs-head { margin-bottom: 10px; }
.module-tab-pane { min-height: 120px; }
</style>
<style>
/* 导入结果弹窗:el-dialog 默认 append-to-body,DOM 挂在 body 上,scoped 选择器匹配不到,必须写全局 */
.import-result-dialog { border-radius: 10px; overflow: hidden; }
.import-result-dialog .el-dialog__header { cursor: move; user-select: none; -webkit-user-select: none; }
.import-result-dialog .el-dialog__headerbtn { cursor: pointer; }
.import-result-dialog .el-dialog__body { max-height: calc(100vh - 210px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 20px; }
.import-result-dialog .el-dialog__footer { border-top: 1px solid #EBEEF5; padding: 10px 20px; }
.import-result-dialog .ird-body { line-height: 1.7; font-size: 13px; color: #303133; }
.import-result-dialog .ird-level { margin-bottom: 8px; font-weight: 600; }
.import-result-dialog .ird-success { color: #67C23A; }
.import-result-dialog .ird-warning { color: #E6A23C; }
.import-result-dialog .ird-error { color: #F56C6C; }
</style>