From e9f1ee7341bcf55904f6c0d31aa91ff52aa441dc Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期二, 22 九月 2026 11:28:40 +0800
Subject: [PATCH] feat(数据查看): 按市州汇总(任意起止月/自动上年同期/时点列不参与/排名可选) + 导出 Excel,新增 CityRollupService 与 3 个接口

---
 traffic-audit-web/src/views/DataView.vue |  212 +++++++++++++++++++++++++++++++++++++++++++++++-----
 1 files changed, 189 insertions(+), 23 deletions(-)

diff --git a/traffic-audit-web/src/views/DataView.vue b/traffic-audit-web/src/views/DataView.vue
index 7b0d59e..d72dc3a 100644
--- a/traffic-audit-web/src/views/DataView.vue
+++ b/traffic-audit-web/src/views/DataView.vue
@@ -17,29 +17,59 @@
       <el-main>
         <h3>瀵煎叆鏁版嵁鏌ョ湅</h3>
         <div class="toolbar">
-          <span class="toolbar-label">鎶ヨ〃鏈�</span>
-          <el-date-picker v-model="period" type="month" placeholder="鎶ヨ〃鏈燂紙鍏ㄩ儴锛�" value-format="yyyy-MM" clearable style="width:150px" @change="doQuery"></el-date-picker>
+          <template v-if="!isRollup">
+            <span class="toolbar-label">鎶ヨ〃鏈�</span>
+            <el-date-picker v-model="period" type="month" placeholder="鎶ヨ〃鏈燂紙鍏ㄩ儴锛�" value-format="yyyy-MM" clearable style="width:150px" @change="doQuery"></el-date-picker>
+          </template>
           <span class="toolbar-sep"></span>
           <span class="toolbar-label">鏁版嵁绫诲瀷</span>
-          <el-radio-group v-model="activeModule" size="small" @change="onModuleChange">
-            <el-radio-button v-for="g in modules" :key="g.key" :label="g.key">{{ g.name }}</el-radio-button>
-          </el-radio-group>
-          <el-select v-model="type" size="small" style="width:250px" placeholder="閫夋嫨鏁版嵁绫诲瀷" @change="onTypeSelect">
-            <el-option-group :label="activeTypes.name">
-              <el-option v-for="t in activeTypes.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
+          <el-select v-model="type" size="small" style="width:300px" placeholder="閫夋嫨鏁版嵁绫诲瀷" filterable
+                     popper-class="data-type-select" :title="currentTypeLabel" @change="onTypeSelect">
+            <el-option-group v-for="g in modules" :key="g.key" :label="g.name">
+              <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
             </el-option-group>
           </el-select>
-          <el-input v-model="keyword" placeholder="浼佷笟/甯傚窞鍚嶇О" clearable style="width:180px" @keyup.enter.native="doQuery"></el-input>
-          <el-button type="primary" size="small" @click="doQuery">鏌ヨ</el-button>
+          <template v-if="canRollup">
+            <span class="toolbar-label">瑙嗗浘</span>
+            <el-radio-group v-model="viewMode" size="small" @change="onViewChange">
+              <el-radio-button label="detail">鏄庣粏</el-radio-button>
+              <el-radio-button label="rollup">鎸夊競宸炴眹鎬�</el-radio-button>
+            </el-radio-group>
+          </template>
+          <template v-if="isRollup">
+            <span class="toolbar-label">璧锋鏈�</span>
+            <el-date-picker v-model="from" type="month" placeholder="璧峰鏈�" value-format="yyyy-MM" style="width:130px"></el-date-picker>
+            <span>~</span>
+            <el-date-picker v-model="to" type="month" placeholder="缁撴潫鏈�" value-format="yyyy-MM" style="width:130px"></el-date-picker>
+            <span class="toolbar-label">鎺掑悕</span>
+            <el-select v-model="rankMode" size="small" style="width:150px">
+              <el-option label="鐣欑┖" value="blank"></el-option>
+              <el-option label="鎸夌疮璁″�奸噸鎺�" value="recompute"></el-option>
+            </el-select>
+            <el-button type="primary" size="small" @click="loadSummary">姹囨��</el-button>
+            <el-button type="success" size="small" icon="el-icon-download" @click="exportSummary">瀵煎嚭 Excel</el-button>
+          </template>
+          <template v-if="hasPeriodType">
+            <span class="toolbar-label">鍙e緞</span>
+            <el-select v-model="periodType" size="small" style="width:110px" @change="doQuery">
+              <el-option label="鍏ㄩ儴" value=""></el-option>
+              <el-option label="褰撴湀" value="MONTH"></el-option>
+              <el-option label="绱" value="CUMULATIVE"></el-option>
+            </el-select>
+          </template>
+          <template v-if="!isRollup">
+            <el-input v-model="keyword" placeholder="浼佷笟/甯傚窞鍚嶇О" clearable style="width:180px" @keyup.enter.native="doQuery"></el-input>
+            <el-button type="primary" size="small" @click="doQuery">鏌ヨ</el-button>
+          </template>
           <el-button size="small" icon="el-icon-full-screen" @click="toggleFullscreen">鍏ㄥ睆鏌ョ湅</el-button>
         </div>
-        <div class="load-hint">鍏� {{ total }} 鏉¤褰曪紝宸插姞杞� {{ records.length }} 鏉�</div>
+        <div class="load-hint">鍏� {{ total }} 鏉¤褰曪紝宸插姞杞� {{ records.length }} 鏉�<span v-if="isRollup && summaryHint" class="rollup-hint">銆�|銆�{{ summaryHint }}</span></div>
         <div class="table-wrap" ref="tableWrap">
           <div class="table-area">
             <el-table :data="records" border v-loading="loading" size="small" style="width:100%" height="100%">
-              <el-table-column v-for="c in visibleColumns" :key="c.prop" :prop="c.prop" :label="c.label" :min-width="c.width" show-overflow-tooltip>
+              <el-table-column v-for="c in renderColumns" :key="c.prop" :prop="c.prop" :label="c.label" :min-width="c.width" :fixed="c.fixed ? 'left' : false" show-overflow-tooltip>
                 <template slot-scope="scope">
-                  <span>{{ formatCell(scope.row[c.prop]) }}</span>
+                  <span>{{ formatCell(scope.row[c.prop], c.prop) }}</span>
                 </template>
               </el-table-column>
             </el-table>
@@ -101,6 +131,13 @@
       activeModule: 'freight',
       type: 'h2032',
       period: '',
+      periodType: '',
+      viewMode: 'detail',
+      rollupTypes: [],
+      from: '',
+      to: '',
+      rankMode: 'blank',
+      summaryHint: '',
       keyword: '',
       page: 1,
       size: 50,
@@ -112,18 +149,53 @@
     }
   },
   computed: {
+    canRollup() {
+      return (this.rollupTypes || []).some(t => t.type === this.type)
+    },
+    isRollup() {
+      return this.viewMode === 'rollup' && this.canRollup
+    },
+    // 瑙勪笂瑙勪笅鎷嗗垎绛夎〃鏈�"鍙e緞"鍒楋紙褰撴湀/绱锛夛紝鎶婂畠鍋氭垚涓婃柟绛涢�夎�屼笉鏄〃鏍煎垪
+    hasPeriodType() {
+      return (this.columns || []).some(c => c.prop === 'periodType')
+    },
+    currentTypeLabel() {
+      for (const g of this.modules) {
+        const t = g.types.find(x => x.value === this.type)
+        if (t) return g.name + ' 路 ' + t.label
+      }
+      return this.type
+    },
     activeTypes() {
       const g = this.modules.find(x => x.key === this.activeModule) || this.modules[0]
       return g
     },
     visibleColumns() {
-      const hiddenProps = ['enterpriseCode', 'unifiedCreditCode', 'reportUnit']
-      const hiddenLabels = ['浼佷笟浠g爜', '缁熶竴绀句細淇$敤浠g爜', '濉姤鍗曚綅']
-      return (this.columns || []).filter(c =>
-        hiddenProps.indexOf(c.prop) < 0 && hiddenLabels.indexOf(c.label) < 0)
+      return (this.columns || []).filter(c => this.keepColumn(c))
+    },
+    // 宸︿晶鍐荤粨"杩欐槸璋佺殑鏁版嵁"鐨勫垪锛氬競宸�/鍦板尯 + 浼佷笟(椤圭洰)鍚嶇О/杞︾墝鍙凤紝鏈�澶� 3 鍒椼��
+    // 鎶ヨ〃鏈熶笌鎵�灞炲湴鍖轰笉鍙備笌鍐荤粨锛堟姤琛ㄦ湡鏄瓫閫夋潯浠讹紝鎵�灞炲湴鍖烘槸鍖哄垝浠g爜锛屽凡鐢卞競宸炲垪鏇夸唬锛夈��
+    renderColumns() {
+      const stickyProps = ['city', 'regionName', 'region', 'periodType', 'enterpriseName', 'projectName', 'plateNo']
+      const cols = this.visibleColumns
+      let count = 0
+      while (count < cols.length && count < 3 && stickyProps.indexOf(cols[count].prop) >= 0) {
+        count++
+      }
+      return cols.map((c, i) => Object.assign({}, c, { fixed: i < count }))
     }
   },
   methods: {
+    // 鏁版嵁鏌ョ湅缁熶竴鍒楄繃婊わ細鍐呴儴鏍囪瘑鍒椾笉灞曠ず锛涙姤琛ㄦ湡锛堢敱绛涢�夋潯浠朵綋鐜帮級涓庢墍灞炲湴鍖猴紙鍖哄垝浠g爜锛屽凡琚競宸炲垪鏇夸唬锛変笉灞曠ず
+    keepColumn(c) {
+      if (!c) return false
+      // 鍙e緞鍒椾笂绉讳负绛涢�夛紱鎷嗗垎琛ㄨ揣杩愰噺涓夊垪婧愭枃浠舵湭鎻愪緵锛堝叏绌猴級鏁呬笉灞曠ず
+      const hiddenProps = ['enterpriseCode', 'unifiedCreditCode', 'reportUnit', 'projectId', 'regionCode', 'reportPeriod',
+        'periodType', 'aboveScaleFreight', 'belowScaleFreight', 'totalFreight']
+      const hiddenLabels = ['浼佷笟浠g爜', '缁熶竴绀句細淇$敤浠g爜', '濉姤鍗曚綅', '椤圭洰ID', '鎵�灞炲湴鍖�', '鎶ヨ〃鏈�', '鍙e緞']
+      if (hiddenProps.indexOf(c.prop) >= 0 || hiddenLabels.indexOf(c.label) >= 0) return false
+      return true
+    },
     onModuleChange(key) {
       const g = this.modules.find(x => x.key === key)
       if (!g || !g.types.length) return
@@ -132,7 +204,69 @@
       }
       this.doQuery()
     },
-    onTypeSelect() { this.doQuery() },
+    loadRollupTypes() {
+      api.get('/data/rollupTypes').then(res => {
+        // 鎷︽埅鍣ㄥ凡杩斿洖鍝嶅簲浣擄紝鏁� res.data 鍗崇被鍨嬫暟缁�
+        this.rollupTypes = (res && res.data) || []
+      }).catch(() => { this.rollupTypes = [] })
+    },
+    onViewChange() {
+      this.records = []
+      this.total = 0
+      if (this.isRollup) {
+        if (!this.to) this.to = this.period || this.defaultMonth()
+        if (!this.from) this.from = this.to.substring(0, 4) + '-01'
+        this.loadSummary()
+      } else {
+        this.summaryHint = ''
+        this.doQuery()
+      }
+    },
+    defaultMonth() {
+      const now = new Date()
+      return now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2)
+    },
+    loadSummary() {
+      if (!this.from || !this.to) { this.$message.warning('璇峰厛閫夋嫨璧锋鏈�'); return }
+      this.loading = true
+      api.get('/data/summary', { params: { type: this.type, from: this.from, to: this.to, rankMode: this.rankMode }, timeout: 120000 })
+        .then(res => {
+          const d = res.data || {}
+          this.columns = d.columns || []
+          this.records = d.records || []
+          this.total = this.records.length
+          this.summaryHint = (d.label || '') + '銆�' + d.from + ' ~ ' + d.to + ' 绱锛涘悓姣斿熀鏁� ' + d.lastFrom + ' ~ ' + d.lastTo
+            + '锛涙帓鍚嶅彛寰勶細' + (d.rankMode === 'recompute' ? '鎸夌疮璁″�奸噸鎺�' : '鐣欑┖')
+        })
+        .catch(e => { if (!e || !e._toast) this.$message.error('姹囨�绘煡璇㈠け璐�') })
+        .finally(() => { this.loading = false })
+    },
+    exportSummary() {
+      if (!this.from || !this.to) { this.$message.warning('璇峰厛閫夋嫨璧锋鏈�'); return }
+      api.get('/data/export', {
+        params: { type: this.type, from: this.from, to: this.to, rankMode: this.rankMode },
+        responseType: 'blob', timeout: 180000
+      }).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.type + '_' + this.from + '_' + this.to + '.xlsx'
+        document.body.appendChild(a)
+        a.click()
+        document.body.removeChild(a)
+        window.URL.revokeObjectURL(url)
+        this.$message.success('姹囨�昏〃宸插鍑�')
+      }).catch(() => this.$message.error('瀵煎嚭澶辫触'))
+    },
+    onTypeSelect() {
+      const g = this.modules.find(x => x.types.some(t => t.value === this.type))
+      if (g) this.activeModule = g.key
+      this.periodType = ''
+      this.viewMode = 'detail'
+      this.summaryHint = ''
+      this.doQuery()
+    },
     doQuery() {
       this.page = 1
       this.records = []
@@ -146,13 +280,14 @@
           type: this.type,
           period: this.period || undefined,
           keyword: this.keyword || undefined,
+          periodType: this.periodType || undefined,
           page: this.page,
           size: this.size
         }
       }).then(res => {
         const d = res.data || {}
         this.records = d.records || []
-        this.columns = (d.columns || []).filter(c => ['enterpriseCode', 'unifiedCreditCode', 'reportUnit'].indexOf(c.prop) < 0 && ['浼佷笟浠g爜', '缁熶竴绀句細淇$敤浠g爜', '濉姤鍗曚綅'].indexOf(c.label) < 0)
+        this.columns = d.columns || []
         this.total = d.total || 0
         if (this.page > 1 && this.records.length === 0) {
           this.page = 1
@@ -188,6 +323,7 @@
           type: this.type,
           period: this.period || undefined,
           keyword: this.keyword || undefined,
+          periodType: this.periodType || undefined,
           page: nextPage,
           size: this.size
         }
@@ -201,7 +337,7 @@
         this.records = this.records.concat(addRows)
         this.page = nextPage
         if (d.total != null) this.total = d.total
-        if (d.columns && d.columns.length) this.columns = d.columns.filter(c => ['enterpriseCode', 'unifiedCreditCode', 'reportUnit'].indexOf(c.prop) < 0 && ['浼佷笟浠g爜', '缁熶竴绀句細淇$敤浠g爜', '濉姤鍗曚綅'].indexOf(c.label) < 0)
+        if (d.columns && d.columns.length) this.columns = d.columns
       }).catch(e => {
         if (e && e._toast) return
         this.$message.error('鍔犺浇鏇村澶辫触')
@@ -238,9 +374,21 @@
         document.exitFullscreen()
       }
     },
-    formatCell(v) {
+    // 鏄剧ず鏍煎紡鍖栵紙涓嶆敼鍔ㄥ簳灞傛暟鎹紝瀵煎嚭浠嶄负鍏ㄧ簿搴︼級锛�
+    //  - 姣旂巼绫诲垪锛堢幆姣�/鍚屾瘮/澧為��/鍗犳瘮锛夊簱鍐呮槸灏忔暟锛�0.045 = 4.5%锛夛紝鏄剧ず涓虹櫨鍒嗘瘮锛�
+    //  - 鎺掑悕绫诲垪鏄剧ず鏁存暟锛�
+    //  - 鍏朵綑鏁板�煎崈鍒嗕綅銆佷繚鐣欎袱浣嶅皬鏁帮紱鏁存暟鍘熸牱銆�
+    formatCell(v, prop) {
       if (v === null || v === undefined || v === '') return '-'
-      return v
+      const s = typeof v === 'string' ? v.trim() : v
+      if (typeof v !== 'number' && !/^-?\d+(\.\d+)?$/.test(String(s))) return v
+      const n = Number(s)
+      if (!isFinite(n)) return v
+      const key = String(prop || '').toLowerCase()
+      if (/rank/.test(key)) return Math.round(n).toLocaleString('zh-CN')
+      if (/(rate|yoy|mom|growth|ratio|percent|share)/.test(key)) return (n * 100).toFixed(2) + '%'
+      if (Number.isInteger(n)) return n.toLocaleString('zh-CN')
+      return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
     }
   },
   mounted() {
@@ -248,6 +396,7 @@
     if (q.type) this.type = q.type
     if (q.period) this.period = q.period
     if (q.keyword) this.keyword = q.keyword
+    this.loadRollupTypes()
     this.loadData()
     window.addEventListener('scroll', this.onScroll, { passive: true })
     if (this.$refs.tableWrap) {
@@ -275,10 +424,27 @@
 #app .toolbar-label { color:#606266; font-weight:600; white-space:nowrap; }
 #app .toolbar-sep { width:1px; height:22px; background:#DCDFE6; margin:0 2px; }
 #app .load-hint { color:#909399; font-size:12px; margin-bottom:6px; }
+#app .rollup-hint { color:#409EFF; }
 #app .table-wrap { display:flex; flex-direction:column; height:calc(100vh - 250px); min-height:300px; }
 #app .table-area { flex:1 1 auto; min-height:0; overflow:hidden; }
 #app .table-area .el-table { width:100%; height:100%; }
 #app .table-area:fullscreen { padding:20px; background:#fff; overflow:auto; }
 #app .table-wrap:fullscreen { padding:20px; background:#fff; overflow:auto; display:flex; flex-direction:column; height:100vh; }
 #app .table-wrap:fullscreen .table-area { flex:1 1 auto; min-height:0; }
-</style>
\ No newline at end of file
+</style>
+<style>
+/* 鏁版嵁绫诲瀷涓嬫媺锛欵lement UI 榛樿鎶婂垎缁勬爣棰樺仛鎴� 12px 鐏拌壊灏忓瓧锛岀湅璧锋潵"姣旈�夐」鏇村皬鏇存贰"銆�
+   杩欓噷鎶婂畠鏀规垚鏄庣‘鐨勭粍鏍囬鏍峰紡锛堝姞娣便�佸姞绮椼�佺暐澶с�佸甫搴曡壊锛夛紝涓庨�夐」褰㈡垚鏈夋剰鐨勫眰绾с�� */
+.data-type-select .el-select-group__title {
+  font-size: 13px;
+  font-weight: 700;
+  color: #303133;
+  line-height: 28px;
+  background: #F5F7FA;
+  padding-left: 12px;
+  letter-spacing: .5px;
+}
+.data-type-select .el-select-group__title::after { display: none; }
+.data-type-select .el-select-group { padding-bottom: 6px; }
+.data-type-select .el-select-group .el-select-dropdown__item { padding-left: 20px; }
+</style>

--
Gitblit v1.9.1