From ec9b25a0799f3b39b4dea2d2ca8ea8491dddbaf4 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期四, 27 八月 2026 15:01:00 +0800
Subject: [PATCH] Merge partner-work: 合并双方开发结果(我方验证码/登出/布局 + 对方业务模块)

---
 traffic-audit-web/src/views/AuditResult.vue |  508 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 475 insertions(+), 33 deletions(-)

diff --git a/traffic-audit-web/src/views/AuditResult.vue b/traffic-audit-web/src/views/AuditResult.vue
index 4fc27a0..7ef313c 100644
--- a/traffic-audit-web/src/views/AuditResult.vue
+++ b/traffic-audit-web/src/views/AuditResult.vue
@@ -1,6 +1,6 @@
 <template>
-  <el-container>
-    <el-header>浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 瀹℃牳缁撴灉</el-header>
+  <el-container direction="vertical">
+    <app-header title="浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 瀹℃牳缁撴灉"></app-header>
     <el-container>
       <el-aside width="200px">
         <el-menu router default-active="/audit">
@@ -12,56 +12,212 @@
           <el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
           <el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
           <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
-        </el-menu>
+</el-menu>
       </el-aside>
-      <el-main>
-        <h3>瀹℃牳缁撴灉</h3>
-        <el-form :inline="true">
+      <el-main :class="{ 'main-focus': focusMode }" style="display:flex;flex-direction:column;min-height:0">
+        <h3 v-show="!focusMode" style="flex:none;margin:0 0 10px">瀹℃牳缁撴灉</h3>
+        <el-form :inline="true" style="flex:none">
+          <el-form-item label="鎶ヨ〃绫诲瀷">
+            <el-radio-group v-model="reportType" @change="onTypeChange">
+              <el-radio label="H2032">璐ц繍鏈堟姤</el-radio>
+              <el-radio label="H2031">鏃呭鏈堟姤</el-radio>
+              <el-radio label="CITY_BUS">鍩庡競鍏氦鏈堟姤</el-radio>
+              <el-radio label="CITY_TAXI">宸℃父鍑虹鏈堟姤</el-radio>
+              <el-radio label="INVEST">鎶曡祫鏈堟姤</el-radio>
+              <el-radio label="H204">鑳借�楀鎶�</el-radio>
+            
+            
+            
+            </el-radio-group>
+          </el-form-item>
           <el-form-item label="鎶ヨ〃鏈�">
-            <el-date-picker v-model="period" type="month" placeholder="閫夋嫨鏈堜唤" value-format="yyyy-MM"></el-date-picker>
+            <el-date-picker v-model="period" type="month" placeholder="閫夋嫨鏈堜唤" value-format="yyyy-MM" @change="onPeriodChange"></el-date-picker>
           </el-form-item>
           <el-form-item>
             <el-button type="primary" @click="executeAudit" :loading="loading">鎵ц瀹℃牳</el-button>
+            <el-button type="success" plain @click="markAudited" :loading="marking">鏍囪宸插鏍�</el-button>
             <el-button @click="queryResults">鏌ヨ缁撴灉</el-button>
+            <el-button type="success" icon="el-icon-download" @click="exportExcel">瀵煎嚭 Excel</el-button>
+            <el-button v-if="!focusMode" size="small" icon="el-icon-full-screen" @click="focusMode = true">鑱氱劍鏌ョ湅</el-button>
+            <el-button v-else type="primary" size="small" icon="el-icon-crop" @click="focusMode = false">閫�鍑鸿仛鐒�</el-button>
           </el-form-item>
         </el-form>
-        <el-form :inline="true" style="margin-top:12px">
+        <!-- 闂姒傝缁熻鍗� -->
+        <div v-if="results.length && !focusMode" class="sev-cards" style="flex:none">
+          <div class="sev-card sev-red"><b>{{ severityStats.red }}</b> 绾㈣壊闂</div>
+          <div class="sev-card sev-yellow"><b>{{ severityStats.yellow }}</b> 榛勮壊闂</div>
+          <div class="sev-card sev-pending"><b>{{ severityStats.pending }}</b> 寰呭鐞�</div>
+          <div class="sev-card sev-done"><b>{{ severityStats.done }}</b> 宸插鐞�</div>
+        </div>
+        <el-form v-if="!focusMode" :inline="true" style="margin-top:8px;flex:none">
           <el-form-item label="浼佷笟鍚嶇О">
-            <el-input v-model="enterpriseFilter" placeholder="杈撳叆浼佷笟鍚嶇О鍏抽敭瀛�" clearable style="width:220px"></el-input>
+            <el-input v-model="enterpriseFilter" placeholder="杈撳叆浼佷笟鍚嶇О鍏抽敭瀛�" clearable style="width:200px" @keyup.enter.native="queryResults"></el-input>
           </el-form-item>
           <el-form-item label="瀹℃牳瑙勫垯">
-            <el-select v-model="ruleFilter" placeholder="鍏ㄩ儴瑙勫垯" clearable style="width:200px">
+            <el-select v-model="ruleFilter" placeholder="鍏ㄩ儴瑙勫垯" clearable style="width:180px">
               <el-option v-for="rn in ruleOptions" :key="rn" :label="rn" :value="rn"></el-option>
             </el-select>
           </el-form-item>
+          <el-form-item label="瑙嗗浘">
+            <el-radio-group v-model="viewMode">
+              <el-radio label="detail">鎸夋潯鏄庣粏</el-radio>
+              <el-radio label="merged">鎸変紒涓氬悎骞�</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="viewMode === 'detail'">
+            <el-button size="small" type="success" :disabled="!batchPending.length" @click="reviewBatch('CONFIRMED')">鎵归噺纭({{ batchPending.length }})</el-button>
+            <el-button size="small" type="info" :disabled="!batchPending.length" @click="reviewBatch('IGNORED')">鎵归噺蹇界暐({{ batchPending.length }})</el-button>
+          </el-form-item>
           <el-form-item>
             <el-button size="small" @click="clearFilters">娓呯┖杩囨护</el-button>
+            <el-button size="small" plain @click="statsCollapsed = !statsCollapsed">{{ statsCollapsed ? '灞曞紑' : '鏀惰捣' }}瑙勫垯缁熻</el-button>
           </el-form-item>
           <el-form-item>
-            <span style="color:#909399;font-size:12px">鍏� {{ results.length }} 鏉★紝褰撳墠鏄剧ず {{ filteredResults.length }} 鏉�</span>
+            <span style="color:#909399;font-size:12px">鍏� {{ results.length }} 鏉¢棶棰橈紝{{ mergedResults.length }} 瀹朵紒涓�</span>
           </el-form-item>
         </el-form>
-        <div class="table-area">
-        <el-table :data="filteredResults" border v-loading="loading" height="100%">
-          <el-table-column prop="enterpriseName" label="浼佷笟鍚嶇О" min-width="200" show-overflow-tooltip></el-table-column>
-          <el-table-column prop="ruleName" label="瀹℃牳瑙勫垯" min-width="140"></el-table-column>
-          <el-table-column prop="actualValue" label="瀹為檯鍊�" min-width="180" show-overflow-tooltip></el-table-column>
-          <el-table-column prop="thresholdValue" label="闃堝��/瀵规瘮" min-width="120"></el-table-column>
-          <el-table-column prop="deviation" label="鍋忓樊璇存槑" min-width="140" show-overflow-tooltip></el-table-column>
-          <el-table-column label="绛夌骇" width="80">
+        <div v-if="ruleStats.length && !statsCollapsed && !focusMode" class="rule-stats" style="flex:none">
+          <div class="stats-cards">
+            <div v-for="rs in sortedRuleStats" :key="rs.ruleId" class="stat-card"
+                 :class="{ 'has-hit': rs.hit > 0, 'is-skipped': rs.skipped, active: ruleFilter === rs.ruleName }"
+                 :title="rs.ruleName + '锛�' + rs.ruleCode + '锛夋鏌�' + rs.checked + ' 鍛戒腑' + rs.hit + '锛堢偣鍑昏繃婊わ級'"
+                 @click="toggleRuleFilter(rs)">
+              <template v-if="rs.skipped">
+                <span class="stat-name">{{ rs.ruleName }}</span>
+                <span class="skip-label">鍩哄噯缂哄け锛屽凡璺宠繃</span>
+              </template>
+              <template v-else>
+                <span class="stat-name">{{ rs.ruleName }}</span>
+                <span class="stat-num"><b :class="{ 'hit-num': rs.hit > 0 }">{{ rs.hit }}</b> / {{ rs.checked }}</span>
+              </template>
+            </div>
+          </div>
+        </div>
+        <div v-if="focusMode" style="flex:none;padding:0 2px 6px;color:#909399;font-size:12px">
+          鍏� {{ results.length }} 鏉¢棶棰橈紝{{ mergedResults.length }} 瀹朵紒涓�
+        </div>
+        <div class="table-area" style="flex:1;min-height:0">
+        <!-- 鏄庣粏瑙嗗浘锛氫竴鏉¢棶棰樹竴寮犲崱鐗� -->
+        <div v-if="viewMode === 'detail'" class="detail-cards-wrap" v-loading="loading">
+          <div class="detail-toolbar">
+            <el-checkbox :value="allSelected" :indeterminate="someSelected" @change="toggleSelectAll">鍏ㄩ�夊綋鍓� {{ filteredResults.length }} 鏉�</el-checkbox>
+            <span class="toolbar-tip">鍕鹃�夊悗鍙壒閲忕‘璁� / 蹇界暐</span>
+          </div>
+          <div v-for="row in filteredResults" :key="row.id" class="detail-card"
+               :class="{ 'card-red': row.alertLevel === 'RED', 'card-yellow': row.alertLevel === 'YELLOW', 'card-done': row.status !== 'PENDING' }">
+            <div class="card-head">
+              <el-checkbox v-model="row._selected" @change="updateBatchSelection"></el-checkbox>
+              <div class="card-ent">
+                <div class="ent-name">{{ row.enterpriseName || row.city || '鏈煡浼佷笟' }}</div>
+                <div class="ent-sub">{{ row.reportPeriod }}</div>
+              </div>
+              <el-tag :type="row.alertLevel === 'RED' ? 'danger' : 'warning'" size="small" effect="dark">{{ row.alertLevel }}</el-tag>
+              <el-tag :type="statusType(row.status)" size="small" effect="plain" class="status-tag">
+                <i :class="statusIcon(row.status)"></i> {{ statusText(row.status) }}
+              </el-tag>
+              <span class="spacer"></span>
+              <el-link v-if="dataType(reportType)" type="primary" :underline="false" size="mini" @click="goData(row)">鏁版嵁</el-link>
+              <el-tag v-if="explainType(reportType)" size="mini" :type="row.verifyExplanation ? 'success' : 'warning'"
+                       style="cursor:pointer" :title="expandedExplain === row.reportId ? '鏀惰捣瑙i噴' : '灞曞紑濉啓/鏌ョ湅瑙i噴'"
+                       @click="toggleExplain(row.reportId)">
+                {{ row.verifyExplanation ? '瑙i噴锛氬凡濉啓' : '瑙i噴锛氭湭濉啓' }}
+              </el-tag>
+              <el-button v-if="row.status === 'PENDING'" size="mini" type="success" icon="el-icon-check" round @click="review(row, 'CONFIRMED')">纭</el-button>
+              <el-button v-if="row.status === 'PENDING'" size="mini" type="info" icon="el-icon-close" round plain @click="review(row, 'IGNORED')">蹇界暐</el-button>
+              <span v-else class="done-text">宸插鐞�</span>
+            </div>
+            <div class="card-body">
+              <div class="rule-title">
+                <b>{{ row.ruleName }}</b>
+                <span class="rule-code">{{ row.ruleCode }}</span>
+              </div>
+              <div class="card-details">
+                <div class="detail-line"><span class="dt-label">瀹為檯</span><span class="dt-val">{{ row.actualValue }}</span></div>
+                <div class="detail-line"><span class="dt-label">鍩哄噯</span><span class="dt-val">{{ row.thresholdValue }}</span></div>
+                <div class="detail-line"><span class="dt-label">宸紓</span><span class="dt-val dt-warn">{{ row.deviation }}</span></div>
+              </div>
+            </div>
+            <div v-if="explainType(reportType) && expandedExplain === row.reportId" class="explain-side" style="margin:0 12px 10px">
+              <div class="explain-head">
+                <span class="explain-title">浼佷笟瑙i噴</span>
+                <el-tag size="mini" :type="row.verifyExplanation ? 'success' : 'warning'">{{ row.verifyExplanation ? '宸插~鍐�' : '鏈~鍐�' }}</el-tag>
+                <span style="flex:1"></span>
+                <el-button size="mini" text @click="expandedExplain = null">鏀惰捣</el-button>
+              </div>
+              <span v-if="row.verifyExplanation" class="explain-cur">{{ row.verifyExplanation }}</span>
+              <el-input v-model="explainDrafts[row.reportId]" type="textarea" :rows="2" resize="none"
+                        placeholder="濉啓/淇敼璇ヤ紒涓氱殑瑙i噴璇存槑鈥�"></el-input>
+              <div class="explain-actions">
+                <el-button size="mini" type="primary" plain :loading="savingExplainId === row.reportId" @click="saveExplain(row)">淇濆瓨瑙i噴</el-button>
+                <el-button v-if="(explainDrafts[row.reportId] || '') !== (row.verifyExplanation || '')" size="mini" text @click="resetDraft(row.reportId)">閲嶇疆</el-button>
+              </div>
+            </div>
+          </div>
+          <div v-if="!filteredResults.length" class="empty-tip">
+            <el-empty description="褰撳墠绛涢�夋潯浠朵笅娌℃湁闂鏁版嵁"></el-empty>
+          </div>
+        </div>
+        <!-- 鍚堝苟瑙嗗浘 -->
+        <el-table v-else :data="mergedResults" border stripe v-loading="loading" height="100%"
+          :header-cell-style="{ background: '#F5F7FA', color: '#303133', fontWeight: 600 }">
+          <el-table-column label="浼佷笟" min-width="190">
             <template slot-scope="scope">
-              <el-tag :type="scope.row.alertLevel === 'RED' ? 'danger' : 'warning'">{{ scope.row.alertLevel }}</el-tag>
+              <div style="font-weight:600">{{ scope.row.enterpriseName }}</div>
+              <div style="color:#909399;font-size:12px;margin-top:2px">鍏� {{ scope.row.items.length }} 鏉¢棶棰�</div>
             </template>
           </el-table-column>
-          <el-table-column prop="status" label="鐘舵��" width="100">
+          <el-table-column label="瀹℃牳闂鏄庣粏" min-width="520">
             <template slot-scope="scope">
-              <el-tag :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag>
+              <div v-for="(item, i) in scope.row.items" :key="item.id"
+                   style="padding:6px 0;border-bottom:1px dashed #EBEEF5"
+                   :class="{ 'no-border': i === scope.row.items.length - 1 }">
+                <div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px">
+                  <b>{{ item.ruleName }}</b>
+                  <el-tag :type="item.alertLevel === 'RED' ? 'danger' : 'warning'" size="mini">{{ item.alertLevel }}</el-tag>
+                  <el-tag :type="statusType(item.status)" size="mini">{{ statusText(item.status) }}</el-tag>
+                  <el-link v-if="dataType(reportType)" type="primary" :underline="false" size="mini" @click="goData(item)">鏁版嵁</el-link>
+                  <span style="flex:1"></span>
+                  <el-button v-if="item.status === 'PENDING'" size="mini" type="success" @click="review(item, 'CONFIRMED')">纭</el-button>
+                  <el-button v-if="item.status === 'PENDING'" size="mini" type="info" @click="review(item, 'IGNORED')">蹇界暐</el-button>
+                </div>
+                <div style="color:#606266;font-size:12px;line-height:1.6;margin-top:2px">
+                  <span>瀹為檯锛歿{ item.actualValue }}</span>
+                  <span style="margin:0 8px">鍩哄噯锛歿{ item.thresholdValue }}</span>
+                  <span>宸紓锛歿{ item.deviation }}</span>
+                </div>
+              </div>
             </template>
           </el-table-column>
-          <el-table-column label="鎿嶄綔" width="160">
+          <el-table-column label="浼佷笟瑙i噴" width="340">
             <template slot-scope="scope">
-              <el-button v-if="scope.row.status === 'PENDING'" size="mini" type="success" @click="review(scope.row, 'CONFIRMED')">纭</el-button>
-              <el-button v-if="scope.row.status === 'PENDING'" size="mini" type="info" @click="review(scope.row, 'IGNORED')">蹇界暐</el-button>
+              <div v-if="explainType(reportType) && scope.row.items[0]" class="explain-cell">
+                <div v-if="expandedExplain !== scope.row.items[0].reportId" class="explain-compact">
+                  <el-tag size="mini" :type="scope.row.items[0].verifyExplanation ? 'success' : 'warning'">{{ scope.row.items[0].verifyExplanation ? '宸插~鍐�' : '鏈~鍐�' }}</el-tag>
+                  <div v-if="scope.row.items[0].verifyExplanation" class="explain-preview">{{ scope.row.items[0].verifyExplanation }}</div>
+                  <el-button size="mini" text type="primary" @click="expandedExplain = scope.row.items[0].reportId">濉啓/淇敼</el-button>
+                </div>
+                <div v-else class="explain-side">
+                  <div class="explain-head">
+                    <span class="explain-title">浼佷笟瑙i噴</span>
+                    <span style="flex:1"></span>
+                    <el-button size="mini" text @click="expandedExplain = null">鏀惰捣</el-button>
+                  </div>
+                  <span v-if="scope.row.items[0].verifyExplanation" class="explain-cur">{{ scope.row.items[0].verifyExplanation }}</span>
+                  <el-input v-model="explainDrafts[scope.row.items[0].reportId]" type="textarea" :rows="2" resize="none"
+                            placeholder="濉啓/淇敼璇ヤ紒涓氱殑瑙i噴璇存槑鈥�"></el-input>
+                  <div class="explain-actions">
+                    <el-button size="mini" type="primary" plain :loading="savingExplainId === scope.row.items[0].reportId" @click="saveExplain(scope.row.items[0])">淇濆瓨瑙i噴</el-button>
+                    <el-button v-if="(explainDrafts[scope.row.items[0].reportId] || '') !== (scope.row.items[0].verifyExplanation || '')" size="mini" text @click="resetDraft(scope.row.items[0].reportId)">閲嶇疆</el-button>
+                  </div>
+                </div>
+              </div>
+              <span v-else style="color:#C0C4CC;font-size:12px">鈥�</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="鎿嶄綔" width="130">
+            <template slot-scope="scope">
+              <el-button v-if="hasPending(scope.row)" size="mini" type="success" plain @click="reviewAll(scope.row, 'CONFIRMED')">鍏ㄩ儴纭</el-button>
+              <el-button v-if="hasPending(scope.row)" size="mini" type="info" plain @click="reviewAll(scope.row, 'IGNORED')">鍏ㄩ儴蹇界暐</el-button>
             </template>
           </el-table-column>
         </el-table>
@@ -71,12 +227,18 @@
   </el-container>
 </template>
 <script>
+import AppHeader from '@/components/AppHeader.vue'
 import api from '@/api'
+const DATA_TYPE_MAP = { H2032: 'h2032', H2031: 'h2031', CITY_BUS: 'cityBus', CITY_TAXI: 'cityTaxi' }
+const EXPLAIN_TYPES = ['H2032', 'H2031', 'CITY_BUS', 'CITY_TAXI']
 export default {
+  components: { AppHeader },
   name: 'AuditResult',
   data() {
     return {
-      period: '', loading: false, results: [], enterpriseFilter: '', ruleFilter: ''
+      period: '', reportType: 'H2032', loading: false, marking: false, results: [], ruleStats: [], statsCollapsed: false,
+      enterpriseFilter: '', ruleFilter: '', viewMode: 'merged', batchSelection: [], focusMode: false,
+      explainDrafts: {}, savingExplainId: null, expandedExplain: null
     }
   },
   computed: {
@@ -84,6 +246,25 @@
       const names = []
       this.results.forEach(r => { if (r.ruleName && names.indexOf(r.ruleName) < 0) names.push(r.ruleName) })
       return names
+    },
+    severityStats() {
+      const s = { red: 0, yellow: 0, pending: 0, done: 0 }
+      this.results.forEach(r => {
+        if (r.alertLevel === 'RED') s.red++
+        else if (r.alertLevel === 'YELLOW') s.yellow++
+        if (r.status === 'PENDING') s.pending++
+        else s.done++
+      })
+      return s
+    },
+    batchPending() { return this.batchSelection.filter(x => x.status === 'PENDING') },
+    allSelected() {
+      const list = this.filteredResults
+      return list.length > 0 && list.every(r => r._selected)
+    },
+    someSelected() {
+      const list = this.filteredResults
+      return list.some(r => r._selected) && !this.allSelected
     },
     filteredResults() {
       const kw = (this.enterpriseFilter || '').trim()
@@ -94,49 +275,310 @@
         if (rule && r.ruleName !== rule) return false
         return true
       })
+    },
+    sortedRuleStats() {
+      const arr = (this.ruleStats || []).slice()
+      arr.sort((a, b) => {
+        const ah = a.hit > 0 ? 1 : 0
+        const bh = b.hit > 0 ? 1 : 0
+        if (ah !== bh) return bh - ah
+        return (a.ruleId || 0) - (b.ruleId || 0)
+      })
+      return arr
+    },
+    mergedResults() {
+      const map = new Map()
+      this.filteredResults.forEach(r => {
+        const key = r.enterpriseName || r.actualValue || '鏈煡浼佷笟'
+        if (!map.has(key)) map.set(key, [])
+        map.get(key).push(r)
+      })
+      return Array.from(map.entries())
+        .map(([name, items]) => ({ enterpriseName: name, items }))
+        .sort((a, b) => b.items.length - a.items.length)
     }
   },
   methods: {
+    dataType(t) { return DATA_TYPE_MAP[t] || '' },
+    explainType(t) { return EXPLAIN_TYPES.indexOf(t) >= 0 },
+    goData(row) {
+      const type = DATA_TYPE_MAP[this.reportType]
+      if (!type) return
+      this.$router.push({ path: '/data', query: { type, period: row.reportPeriod, keyword: row.enterpriseName || row.city || '' } })
+    },
+    goExplain(row) {
+      this.$router.push({ path: '/explain', query: { reportType: this.reportType, keyword: row.enterpriseName || row.city || '', period: row.reportPeriod } })
+    },
+    onTypeChange() {
+      this.ruleFilter = ''; this.batchSelection = []; this.results = []; this.ruleStats = []
+      this.period = ''
+      this.loadLatestPeriod(true)
+    },
+    onPeriodChange() {
+      if (this.period) this.queryResults(); else { this.results = []; this.ruleStats = [] }
+    },
+    loadLatestPeriod(autoQuery) {
+      api.get('/audit/latestPeriod', { params: { reportType: this.reportType } })
+        .then(res => {
+          const p = res.data
+          if (p && !this.period) {
+            this.period = p
+            if (autoQuery) this.queryResults()
+          } else if (autoQuery && !p) {
+            this.results = []; this.ruleStats = []
+          }
+        })
+        .catch(() => {})
+    },
     executeAudit() {
       if (!this.period) { this.$message.warning('璇烽�夋嫨鎶ヨ〃鏈�'); return }
       this.loading = true
       const fd = new FormData()
       fd.append('period', this.period)
+      fd.append('reportType', this.reportType)
       api.post('/audit/execute', fd)
         .then(res => {
           const count = (res.data || []).length
-          this.$message.success('瀹℃牳瀹屾垚锛屽叡 ' + count + ' 鏉¢棶棰�')
-          return api.get('/audit/results', { params: { period: this.period } })
+          return api.get('/audit/ruleStats', { params: { period: this.period, reportType: this.reportType } })
+            .then(stats => {
+              const list = stats.data || []
+              const checked = list.reduce((s, r) => Math.max(s, Number(r.checked) || 0), 0)
+              if (list.length === 0) {
+                this.$message.warning('璇ユ姤琛ㄧ被鍨嬫病鏈夊惎鐢ㄥ鏍歌鍒欙紝鏈墽琛屽鏍�')
+              } else if (checked === 0) {
+                this.$message.warning('璇ユ姤琛ㄦ湡娌℃湁鏁版嵁锛屾湭鎵ц瀹℃牳锛堣鍏堝鍏ユ暟鎹級')
+              } else {
+                const skipped = list.filter(x => x.skipped)
+                if (skipped.length > 0) {
+                  const names = skipped.map(x => x.ruleCode).join('銆�')
+                  this.$message.warning('瀹℃牳瀹屾垚锛屽叡 ' + count + ' 鏉¢棶棰橈紱' + names + ' 鍥犲熀鍑嗘暟鎹紙杩愭斂/杞ㄨ抗/涓婃湀绛夛級缂哄け宸茶烦杩�')
+                } else {
+                  this.$message.success('瀹℃牳瀹屾垚锛屽叡 ' + count + ' 鏉¢棶棰�')
+                }
+              }
+            })
         })
-        .then(res => { this.results = res.data || [] })
+        .then(() => api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } }))
+        .then(res => { this.applyResults(res.data) })
+        .then(() => this.loadRuleStats())
         .catch(() => { this.$message.error('瀹℃牳鎵ц澶辫触') })
         .finally(() => { this.loading = false })
+    },
+    markAudited() {
+      if (!this.period) { this.$message.warning('璇烽�夋嫨鎶ヨ〃鏈�'); return }
+      this.marking = true
+      const fd = new FormData()
+      fd.append('period', this.period)
+      fd.append('reportType', this.reportType)
+      api.post('/audit/markAudited', fd)
+        .then(res => {
+          if (res.data) {
+            this.$message.success('宸叉爣璁拌鏈熴��' + this.reportType + '銆嶄负宸插鏍革紝鎶ヨ〃瀵煎嚭涓嶅啀鎻愮ず鏈鏍�')
+          } else {
+            this.$message.warning('璇ョ被鍨嬫病鏈夊惎鐢ㄨ鍒欐垨璇ユ湡娌℃湁鏁版嵁锛屾湭鍐欏叆鏍囪')
+          }
+          return api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } })
+        })
+        .then(res => { this.applyResults(res.data) })
+        .catch(() => { this.$message.error('鏍囪澶辫触') })
+        .finally(() => { this.marking = false })
     },
     queryResults() {
       if (!this.period) { this.$message.warning('璇烽�夋嫨鎶ヨ〃鏈�'); return }
       this.loading = true
-      api.get('/audit/results', { params: { period: this.period } })
-        .then(res => { this.results = res.data || [] })
+      api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } })
+        .then(res => { this.applyResults(res.data) })
+        .then(() => this.loadRuleStats())
         .catch(() => { this.$message.error('鏌ヨ澶辫触') })
         .finally(() => { this.loading = false })
     },
+    toggleExplain(reportId) {
+      this.expandedExplain = this.expandedExplain === reportId ? null : reportId
+    },
+    applyResults(list) {
+      this.results = (list || []).map(r => Object.assign({}, r, { _selected: false }))
+      this.results.forEach(r => {
+        if (r.reportId && this.explainDrafts[r.reportId] === undefined) {
+          this.$set(this.explainDrafts, r.reportId, r.verifyExplanation || '')
+        }
+      })
+    },
+    saveExplain(row) {
+      const reportId = row.reportId
+      if (!reportId) { this.$message.warning('鏃犳硶瀹氫綅璇ヤ紒涓氱殑鏁版嵁琛�'); return }
+      const explanation = (this.explainDrafts[reportId] || '').trim()
+      this.savingExplainId = reportId
+      const fd = new FormData()
+      fd.append('reportType', this.reportType)
+      fd.append('reportId', reportId)
+      fd.append('explanation', explanation)
+      api.post('/explain/save', fd)
+        .then(() => {
+          this.$message.success('浼佷笟瑙i噴宸蹭繚瀛�')
+          this.$set(this.explainDrafts, reportId, explanation)
+          this.results.forEach(r => { if (r.reportId === reportId) r.verifyExplanation = explanation })
+        })
+        .catch(e => { this.$message.error((e.response && e.response.data && e.response.data.message) || '淇濆瓨澶辫触') })
+        .finally(() => { this.savingExplainId = null })
+    },
+    resetDraft(reportId) {
+      const row = this.results.find(r => r.reportId === reportId)
+      if (row) this.$set(this.explainDrafts, reportId, row.verifyExplanation || '')
+    },
+    loadRuleStats() {
+      if (!this.period) return
+      api.get('/audit/ruleStats', { params: { period: this.period, reportType: this.reportType } })
+        .then(res => { this.ruleStats = res.data || [] })
+        .catch(() => { this.ruleStats = [] })
+    },
+    exportExcel() {
+      if (!this.period) { this.$message.warning('璇烽�夋嫨鎶ヨ〃鏈�'); return }
+      window.open('/api/audit/export?period=' + this.period + '&reportType=' + this.reportType)
+    },
     review(row, status) {
+      if (status === 'IGNORED') {
+        this.$prompt('璇疯緭鍏ュ拷鐣ュ師鍥狅紙鏂逛究浜嬪悗杩芥函锛�', '蹇界暐闂', {
+          inputPlaceholder: '鍘熷洜锛堝彲閫夛級', inputType: 'textarea', confirmButtonText: '纭畾蹇界暐', cancelButtonText: '鍙栨秷'
+        }).then(({ value }) => this.doReview(row, status, (value || '').trim()))
+          .catch(() => {})
+      } else {
+        this.doReview(row, status)
+      }
+    },
+    doReview(row, status, comment) {
       const fd = new FormData()
       fd.append('id', row.id)
       fd.append('status', status)
+      if (comment) fd.append('comment', comment)
       api.post('/audit/review', fd)
-        .then(() => { row.status = status; this.$message.success('宸插鐞�') })
+        .then(() => { row.status = status; this.$message.success(status === 'CONFIRMED' ? '宸茬‘璁�' : '宸插拷鐣�') })
         .catch(() => { this.$message.error('鎿嶄綔澶辫触') })
     },
+    updateBatchSelection() { this.batchSelection = this.filteredResults.filter(r => r._selected) },
+    toggleSelectAll(value) {
+      this.filteredResults.forEach(r => { r._selected = value })
+      this.updateBatchSelection()
+    },
+    reviewBatch(status) {
+      const list = this.batchPending.slice()
+      if (list.length === 0) return
+      if (status === 'IGNORED') {
+        this.$prompt('璇疯緭鍏ュ拷鐣ュ師鍥狅紙搴旂敤浜庨�変腑鐨� ' + list.length + ' 鏉★級', '鎵归噺蹇界暐', {
+          inputPlaceholder: '鍘熷洜锛堝彲閫夛級', inputType: 'textarea', confirmButtonText: '纭畾蹇界暐', cancelButtonText: '鍙栨秷'
+        }).then(({ value }) => this.doReviewBatch(list, status, (value || '').trim()))
+          .catch(() => {})
+      } else {
+        this.doReviewBatch(list, status)
+      }
+    },
+    doReviewBatch(list, status, comment) {
+      let done = 0
+      list.forEach(item => {
+        const fd = new FormData()
+        fd.append('id', item.id)
+        fd.append('status', status)
+        if (comment) fd.append('comment', comment)
+        api.post('/audit/review', fd)
+          .then(() => { item.status = status; done++; if (done === list.length) this.$message.success('宸�' + (status === 'CONFIRMED' ? '纭' : '蹇界暐') + ' ' + done + ' 鏉�') })
+          .catch(() => { this.$message.error('閮ㄥ垎鎿嶄綔澶辫触') })
+      })
+    },
     clearFilters() { this.enterpriseFilter = ''; this.ruleFilter = '' },
+    toggleRuleFilter(rs) {
+      this.ruleFilter = this.ruleFilter === rs.ruleName ? '' : rs.ruleName
+    },
+    hasPending(group) { return group.items.some(x => x.status === 'PENDING') },
+    reviewAll(group, status) {
+      const pending = group.items.filter(x => x.status === 'PENDING')
+      if (pending.length === 0) return
+      if (status === 'IGNORED') {
+        this.$prompt('璇疯緭鍏ュ拷鐣ュ師鍥狅紙搴旂敤浜庤浼佷笟 ' + pending.length + ' 鏉¢棶棰橈級', '蹇界暐', {
+          inputPlaceholder: '鍘熷洜锛堝彲閫夛級', inputType: 'textarea', confirmButtonText: '纭畾蹇界暐', cancelButtonText: '鍙栨秷'
+        }).then(({ value }) => this.doReviewBatch(pending, status, (value || '').trim()))
+          .catch(() => {})
+      } else {
+        this.doReviewBatch(pending, status)
+      }
+    },
     statusType(status) {
       if (status === 'CONFIRMED') return 'success'
       if (status === 'IGNORED') return 'info'
       return 'warning'
+    },
+    statusText(status) {
+      if (status === 'CONFIRMED') return '宸茬‘璁�'
+      if (status === 'IGNORED') return '宸插拷鐣�'
+      return '寰呭鐞�'
+    },
+    statusIcon(status) {
+      if (status === 'CONFIRMED') return 'el-icon-circle-check'
+      if (status === 'IGNORED') return 'el-icon-circle-close'
+      return 'el-icon-time'
     }
+  },
+  mounted() {
+    this.loadLatestPeriod(true)
   }
 }
 </script>
 <style scoped>
 .el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
-</style>
\ No newline at end of file
+.no-border { border-bottom: none !important; }
+.sev-cards { display: flex; gap: 10px; }
+.sev-card { flex: 1; max-width: 180px; border-radius: 6px; padding: 6px 12px; font-size: 13px; color: #606266; }
+.sev-card b { font-size: 20px; margin-right: 4px; }
+.sev-red { background: #FEF0F0; border: 1px solid #FDE2E2; }
+.sev-red b { color: #F56C6C; }
+.sev-yellow { background: #FDF6EC; border: 1px solid #FAECD8; }
+.sev-yellow b { color: #E6A23C; }
+.sev-pending { background: #F4F4F5; border: 1px solid #E9E9EB; }
+.sev-pending b { color: #606266; }
+.sev-done { background: #F0F9EB; border: 1px solid #E1F3D8; }
+.sev-done b { color: #67C23A; }
+.ent-name { font-weight: 600; color: #303133; }
+.ent-sub { color: #909399; font-size: 12px; margin-top: 2px; }
+.rule-name { font-weight: 500; color: #303133; }
+.rule-code { color: #909399; font-size: 12px; margin-top: 2px; }
+.detail-line { display: flex; align-items: baseline; line-height: 1.7; }
+.dt-label { flex: none; width: 34px; color: #909399; font-size: 12px; }
+.dt-val { flex: 1; color: #606266; word-break: break-all; }
+.dt-warn { color: #E6A23C; }
+.status-tag i { margin-right: 3px; }
+.done-text { color: #C0C4CC; font-size: 12px; }
+.explain-side { background: #FAFAFA; border: 1px solid #EBEEF5; border-radius: 6px; padding: 8px 10px; }
+.explain-cell { min-height: 40px; }
+.explain-compact .explain-preview { color: #909399; font-size: 12px; line-height: 1.5; margin: 4px 0; max-height: 40px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-all; }
+.explain-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.explain-title { font-weight: 600; color: #303133; font-size: 13px; }
+.explain-cur { display: block; color: #909399; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-bottom: 6px; }
+.explain-actions { margin-top: 6px; }
+.main-focus { padding: 8px 12px !important; }
+.detail-cards-wrap { flex: 1; min-height: 0; overflow-y: auto; padding: 2px 4px 16px; }
+.detail-toolbar { display: flex; align-items: center; gap: 10px; padding: 0 2px 8px; }
+.toolbar-tip { color: #909399; font-size: 12px; }
+.detail-card { background: #FFF; border: 1px solid #EBEEF5; border-left: 3px solid #E6A23C; border-radius: 6px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
+.detail-card.card-red { border-left-color: #F56C6C; }
+.detail-card.card-yellow { border-left-color: #E6A23C; }
+.detail-card.card-done { opacity: .72; background: #FAFAFA; }
+.card-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px 4px; }
+.card-ent { flex: 1; min-width: 0; }
+.spacer { flex: 1; }
+.card-body { padding: 2px 12px 10px; }
+.rule-title { display: flex; align-items: baseline; gap: 8px; margin: 2px 0 4px; }
+.rule-title b { font-size: 14px; color: #303133; }
+.card-details { display: flex; flex-wrap: wrap; gap: 4px 24px; background: #F8F9FB; border: 1px solid #F0F2F5; border-radius: 4px; padding: 6px 10px; }
+.card-details .detail-line { display: flex; align-items: baseline; line-height: 1.8; min-width: 200px; }
+.empty-tip { padding: 30px 0; }
+.rule-stats { margin: 0 0 8px; }
+.stats-cards { display: flex; overflow-x: auto; gap: 6px; padding: 4px 0; }
+.stat-card { flex: none; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #EBEEF5; border-radius: 14px; padding: 3px 10px; background: #FAFAFA; font-size: 12px; white-space: nowrap; cursor: pointer; }
+.stat-card:hover { border-color: #C0C4CC; }
+.stat-card.has-hit { background: #FDF6EC; border-color: #E6A23C; }
+.stat-card.is-skipped { background: #F2F6FC; border-color: #DCDFE6; opacity: 0.85; }
+.stat-card.active { border-color: #409EFF; box-shadow: 0 0 0 1px #409EFF; }
+.skip-label { color: #909399; font-size: 12px; }
+.stat-name { color: #606266; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
+.stat-num { color: #909399; }
+.stat-num b { font-weight: 700; }
+.hit-num { color: #E6A23C; }
+</style>

--
Gitblit v1.9.1