From bee9e18c3af284ff432a4472f1d409f5a6dc09a7 Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期二, 06 十月 2026 16:59:15 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/main'

---
 traffic-audit-web/src/views/HolidayStat.vue |  690 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 690 insertions(+), 0 deletions(-)

diff --git a/traffic-audit-web/src/views/HolidayStat.vue b/traffic-audit-web/src/views/HolidayStat.vue
new file mode 100644
index 0000000..6875128
--- /dev/null
+++ b/traffic-audit-web/src/views/HolidayStat.vue
@@ -0,0 +1,690 @@
+<template>
+  <el-container direction="vertical">
+    <app-header title="浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 涓撻」缁熻"></app-header>
+    <el-container>
+      <el-aside width="200px">
+        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
+          <el-menu-item index="/dashboard">宸ヤ綔鍙�</el-menu-item>
+          <el-menu-item index="/import">鏁版嵁瀵煎叆</el-menu-item>
+          <el-menu-item index="/data">鏁版嵁鏌ョ湅</el-menu-item>
+          <el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
+          <el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
+          <el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+          <el-submenu index="/special-stats">
+            <template slot="title">涓撻」缁熻</template>
+            <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+          </el-submenu>
+          <el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
+          <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+          <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+        </el-menu>
+      </el-aside>
+      <el-main>
+        <div class="page-scroll">
+          <h3>涓撻」缁熻 - 鑺傚亣鏃�</h3>
+          <div class="toolbar">
+            <el-select v-model="year" style="width:120px" @change="loadOverview">
+              <el-option v-for="item in years" :key="item" :label="item + '骞�'" :value="item"></el-option>
+            </el-select>
+            <el-select v-model="holidayType" style="width:150px" @change="loadOverview">
+              <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+            </el-select>
+            <el-button icon="el-icon-refresh" @click="loadOverview">鍒锋柊</el-button>
+            <el-button icon="el-icon-download" @click="downloadTemplate">涓嬭浇妯℃澘</el-button>
+          </div>
+
+          <el-card shadow="never" class="operation-card">
+            <div class="operation-row">
+              <div class="operation-item">
+                <span class="label">瀵煎叆鏁版嵁</span>
+                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
+                           accept=".xlsx,.xls" :on-change="onFileChange" :on-remove="onFileRemove" :file-list="fileList">
+                  <el-button type="primary" plain icon="el-icon-folder-opened">閫夋嫨鏂囦欢</el-button>
+                </el-upload>
+                <el-button type="primary" icon="el-icon-upload2" :loading="importing" :disabled="!selectedFile" @click="importData">瀵煎叆</el-button>
+              </div>
+              <div class="operation-item">
+                <span class="label">瀵煎嚭鏁版嵁</span>
+                <el-button type="success" icon="el-icon-download" :loading="exporting" @click="exportData">瀵煎嚭 Excel</el-button>
+              </div>
+              <div class="operation-item">
+                <span class="label">瀹㈡祦蹇姤</span>
+                <el-date-picker v-model="bulletinDate" type="date" value-format="yyyy-MM-dd" style="width:160px" placeholder="閫夋嫨鏃ユ湡"></el-date-picker>
+                <el-button type="warning" icon="el-icon-document" :loading="bulletinLoading" @click="downloadBulletin">涓嬭浇蹇姤</el-button>
+              </div>
+              <div class="operation-item">
+                <span class="label">璧板娍鍥�</span>
+                <el-button type="primary" icon="el-icon-picture-outline" :loading="trendLoading" @click="downloadTrend">涓嬭浇璧板娍鍥�</el-button>
+              </div>
+            </div>
+          </el-card>
+
+          <div v-if="importMessage" class="import-message">
+            <pre>{{ importMessage }}</pre>
+          </div>
+
+          <el-card shadow="never" style="margin-top:14px">
+            <div slot="header">{{ year }}骞磠{ holidayTypeLabel }}鑺傚亣鏃ユ暟鎹�</div>
+            <el-table :data="rows" border stripe size="small" v-loading="loading" style="width:100%">
+              <el-table-column prop="dayIndex" label="绗嚑鏃�" width="80" align="center"></el-table-column>
+              <el-table-column prop="date" label="鏃ユ湡" width="120" align="center"></el-table-column>
+              <el-table-column label="閾佽矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.rail) }}</template></el-table-column>
+              <el-table-column label="姘戣埅锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.civilAviation) }}</template></el-table-column>
+              <el-table-column label="閬撹矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.road) }}</template></el-table-column>
+              <el-table-column label="姘磋矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.water) }}</template></el-table-column>
+              <el-table-column label="楂橀�熻溅娴侊紙涓囪締娆★級" width="150" align="right"><template slot-scope="s">{{ formatNumber(s.row.expressway) }}</template></el-table-column>
+              <el-table-column label="鍥界渷閬撹溅娴侊紙涓囪締娆★級" width="160" align="right"><template slot-scope="s">{{ formatNumber(s.row.nationalProvincial) }}</template></el-table-column>
+              <el-table-column label="鍥界渷閬撶幆姣斿熀鏈燂紙涓囪締娆★級" width="190" align="right"><template slot-scope="s">{{ formatNumber(s.row.comparisonBase) }}</template></el-table-column>
+              <el-table-column label="鍥界渷閬撹溅娴佺幆姣�" width="140" align="right"><template slot-scope="s">{{ formatRatio(s.row.nationalProvincialRingRatio) }}</template></el-table-column>
+              <el-table-column label="瀹㈣繍绯绘暟" width="100" align="right"><template slot-scope="s">{{ formatCoefficient(s.row.coefficient) }}</template></el-table-column>
+              <el-table-column label="闈炶惀涓氭�т汉鍛樻祦鍔ㄩ噺锛堜竾浜烘锛�" min-width="200" align="right"><template slot-scope="s">{{ formatNumber(s.row.nonBusinessPersonFlow) }}</template></el-table-column>
+              <el-table-column label="鎬讳汉鍛樻祦鍔ㄩ噺锛堜竾浜烘锛�" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
+            </el-table>
+          </el-card>
+
+          <el-card shadow="never" style="margin-top:14px">
+            <div slot="header">
+              <span>寰�骞村浗鐪侀亾鏁版嵁璋冩暣</span>
+            </div>
+            <div class="operation-row">
+              <div class="operation-item">
+                <span class="label">骞翠唤</span>
+                <el-select v-model="adjustmentYear" style="width:120px" @change="loadAdjustments">
+                  <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + '骞�'" :value="item"></el-option>
+                </el-select>
+              </div>
+              <div v-if="operatorIsAdmin" class="operation-item">
+                <el-button type="primary" icon="el-icon-plus" @click="openAdjustmentDialog()">鏂板璋冩暣</el-button>
+              </div>
+            </div>
+            <el-table :data="adjustments" border stripe size="small" style="margin-top:12px" v-loading="adjustmentLoading">
+              <el-table-column prop="year" label="骞翠唤" width="90" align="center"></el-table-column>
+              <el-table-column prop="flowDate" label="鏃ユ湡" width="130" align="center"></el-table-column>
+              <el-table-column label="璋冩暣鍓嶅浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級" width="240" align="right">
+                <template slot-scope="s">{{ formatOriginalAdjustmentValue(s.row.originalValue) }}</template>
+              </el-table-column>
+              <el-table-column label="璋冩暣鍚庡浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級" width="240" align="right">
+                <template slot-scope="s">{{ formatAdjustmentValue(s.row.adjustedValue) }}</template>
+              </el-table-column>
+              <el-table-column prop="reason" label="璋冩暣鍘熷洜" min-width="280" show-overflow-tooltip></el-table-column>
+              <el-table-column label="鏇存柊鏃堕棿" min-width="170"><template slot-scope="s">{{ formatDateTime(s.row.updatedAt) }}</template></el-table-column>
+              <el-table-column v-if="operatorIsAdmin" label="鎿嶄綔" width="130" align="center">
+                <template slot-scope="s">
+                  <el-button type="text" @click="openAdjustmentDialog(s.row)">缂栬緫</el-button>
+                  <el-button type="text" class="danger-text" @click="deleteAdjustment(s.row)">鍒犻櫎</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+            <el-dialog :title="adjustmentForm.id ? '缂栬緫璋冩暣' : '鏂板璋冩暣'" :visible.sync="adjustmentDialogVisible" width="560px" v-dialog-drag>
+              <el-form label-width="220px">
+                <el-form-item label="骞翠唤">
+                  <el-select v-model="adjustmentForm.year" style="width:100%" @change="onAdjustmentYearChange">
+                    <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + '骞�'" :value="item"></el-option>
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="鏃ユ湡">
+                  <el-date-picker v-model="adjustmentForm.flowDate" type="date" value-format="yyyy-MM-dd"
+                                  style="width:100%" :picker-options="adjustmentDatePickerOptions" placeholder="閫夋嫨鏃ユ湡"
+                                  @change="onAdjustmentDateChange"></el-date-picker>
+                </el-form-item>
+                <el-form-item label="璋冩暣鍓嶅浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級">
+                  <el-input :value="formatOriginalAdjustmentValue(adjustmentForm.originalValue)" readonly
+                            :placeholder="adjustmentOriginalLoading ? '鏌ヨ涓�...' : ''"></el-input>
+                </el-form-item>
+                <el-form-item label="璋冩暣鍚庤溅娴侀噺锛堜竾杈嗘锛�">
+                  <el-input-number v-model="adjustmentForm.adjustedValue" :min="0" :precision="4" :step="0.0001" controls-position="right" style="width:100%"></el-input-number>
+                </el-form-item>
+                <el-form-item label="璋冩暣鍘熷洜">
+                  <el-input v-model="adjustmentForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit></el-input>
+                </el-form-item>
+              </el-form>
+              <div slot="footer">
+                <el-button @click="adjustmentDialogVisible = false">鍙栨秷</el-button>
+                <el-button type="primary" :loading="adjustmentSaving" @click="saveAdjustment">淇濆瓨</el-button>
+              </div>
+            </el-dialog>
+          </el-card>
+
+          <el-card shadow="never" style="margin-top:14px">
+            <div slot="header">
+              <span>瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹�</span>
+              <span class="hint">鐢ㄤ簬鍘熷鏁版嵁鐣欏瓨锛屼笉涓庝笂鏂硅妭鍋囨棩缁熻鏁版嵁鍏宠仈</span>
+            </div>
+            <div class="operation-row">
+              <div class="operation-item">
+                <span class="label">瀵煎叆鏁版嵁</span>
+                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
+                           accept=".xls,.xlsx" :on-change="onObservationFileChange" :on-remove="onObservationFileRemove"
+                           :file-list="observationFileList">
+                  <el-button type="primary" plain icon="el-icon-folder-opened">閫夋嫨鏂囦欢</el-button>
+                </el-upload>
+                <el-button type="primary" icon="el-icon-upload2" :loading="observationImporting"
+                           :disabled="!observationFile" @click="importObservation">瀵煎叆</el-button>
+              </div>
+            </div>
+            <div v-if="observationMessage" class="import-message">
+              <pre>{{ observationMessage }}</pre>
+            </div>
+            <el-table :data="observationDates" border stripe size="small" style="margin-top:12px" v-loading="observationLoading">
+              <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="130" align="center"></el-table-column>
+              <el-table-column prop="stationCount" label="瑙傛祴绔欐暟" width="110" align="right"></el-table-column>
+              <el-table-column label="涓皬瀹㈡祦閲忓悎璁★紙涓囪締娆★級" width="210" align="right">
+                <template slot-scope="s">{{ formatWanVehicles(s.row.smallMediumPassengerTotal) }}</template>
+              </el-table-column>
+              <el-table-column prop="importedAt" label="鏈�杩戝鍏ユ椂闂�" min-width="180"></el-table-column>
+              <el-table-column label="鎿嶄綔" width="150" align="center">
+                <template slot-scope="s">
+                  <el-button type="text" @click="viewObservation(s.row.obsDate)">鏌ョ湅</el-button>
+                  <el-button type="text" class="danger-text" @click="deleteObservation(s.row.obsDate)">鍒犻櫎</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-card>
+
+          <el-dialog :title="'瑙傛祴绔欐槑缁� - ' + observationDialogDate" :visible.sync="observationDialogVisible" width="92%" top="5vh">
+            <el-table :data="observationRows" border stripe size="mini" height="540" v-loading="observationRowsLoading">
+              <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="110" fixed="left" align="center"></el-table-column>
+              <el-table-column prop="stationCode" label="瑙傛祴绔欑紪鍙�" width="130" fixed="left"></el-table-column>
+              <el-table-column prop="stationName" label="瑙傛祴绔欏悕绉�" width="120"></el-table-column>
+              <el-table-column label="瑙傛祴閲岀▼" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.mileage) }}</template></el-table-column>
+              <el-table-column prop="cityName" label="鍦板競鍚嶇О" width="140"></el-table-column>
+              <el-table-column prop="countyName" label="鍖哄幙鍚嶇О" width="110"></el-table-column>
+              <el-table-column prop="direction" label="琛岄┒鏂瑰悜" width="90"></el-table-column>
+              <el-table-column label="涓皬瀹㈡祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallMediumPassenger) }}</template></el-table-column>
+              <el-table-column label="澶у杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largePassenger) }}</template></el-table-column>
+              <el-table-column label="灏忚揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallTruck) }}</template></el-table-column>
+              <el-table-column label="涓揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.mediumTruck) }}</template></el-table-column>
+              <el-table-column label="澶ц揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largeTruck) }}</template></el-table-column>
+              <el-table-column label="鐗瑰ぇ璐ф祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.extraLargeTruck) }}</template></el-table-column>
+              <el-table-column label="闆嗚绠辨祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.containerTruck) }}</template></el-table-column>
+              <el-table-column label="鎽╂墭杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorcycle) }}</template></el-table-column>
+              <el-table-column label="鎷栨媺鏈烘祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.tractor) }}</template></el-table-column>
+              <el-table-column label="瀹㈣溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerTotal) }}</template></el-table-column>
+              <el-table-column label="璐ц溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckTotal) }}</template></el-table-column>
+              <el-table-column label="姹借溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carTotal) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorTotal) }}</template></el-table-column>
+              <el-table-column label="瀹㈣溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerEquivalent) }}</template></el-table-column>
+              <el-table-column label="璐ц溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckEquivalent) }}</template></el-table-column>
+              <el-table-column label="姹借溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carEquivalent) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞﹀綋閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorEquivalent) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞﹂�熷害" width="110" align="right"><template slot-scope="s">{{ formatPlain(s.row.vehicleSpeed) }}</template></el-table-column>
+              <el-table-column label="鎷ユ尋搴�" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.congestionDegree) }}</template></el-table-column>
+              <el-table-column prop="sourceFile" label="鏉ユ簮鏂囦欢鍚�" min-width="220" show-overflow-tooltip></el-table-column>
+            </el-table>
+          </el-dialog>
+        </div>
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script>
+import api from '@/api'
+import AppHeader from '@/components/AppHeader.vue'
+
+export default {
+  components: { AppHeader },
+  name: 'HolidayStat',
+  data() {
+    return {
+      years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
+      year: 2026,
+      holidayType: 'NATIONAL_DAY',
+      holidayTypeOptions: [],
+      rows: [],
+      loading: false,
+      importing: false,
+      exporting: false,
+      bulletinLoading: false,
+      trendLoading: false,
+      selectedFile: null,
+      fileList: [],
+      importMessage: '',
+      allowHistoricalOverwrite: false,
+      bulletinDate: this.previousDateString(),
+      observationFile: null,
+      observationFileList: [],
+      observationImporting: false,
+      observationLoading: false,
+      observationMessage: '',
+      observationDates: [],
+      observationDialogVisible: false,
+      observationDialogDate: '',
+      observationRows: [],
+      observationRowsLoading: false,
+      adjustmentYear: new Date().getFullYear() - 1,
+      adjustments: [],
+      adjustmentLoading: false,
+      adjustmentDialogVisible: false,
+      adjustmentSaving: false,
+      adjustmentOriginalLoading: false,
+      adjustmentOriginalRequestId: 0,
+      adjustmentForm: {
+        id: null,
+        year: new Date().getFullYear() - 1,
+        flowDate: '',
+        originalValue: null,
+        adjustedValue: null,
+        reason: ''
+      }
+    }
+  },
+  computed: {
+    holidayTypeLabel() {
+      const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
+      return item ? item.label : this.holidayType
+    },
+    availableAdjustmentYears() {
+      const currentYear = new Date().getFullYear()
+      return this.years.filter(item => item < currentYear)
+    },
+    adjustmentDatePickerOptions() {
+      return {
+        disabledDate: date => date.getFullYear() !== Number(this.adjustmentForm.year)
+      }
+    }
+  },
+  mounted() {
+    this.loadOptions()
+    this.loadOverview()
+    this.loadObservationDates()
+    this.loadAdjustments()
+  },
+  methods: {
+    previousDateString() {
+      const date = new Date()
+      date.setDate(date.getDate() - 1)
+      const month = String(date.getMonth() + 1).padStart(2, '0')
+      const day = String(date.getDate()).padStart(2, '0')
+      return date.getFullYear() + '-' + month + '-' + day
+    },
+    loadOptions() {
+      api.get('/holiday/config/options').then(res => {
+        const map = (res.data && res.data.holidayTypes) || {}
+        this.holidayTypeOptions = Object.keys(map).map(key => ({ value: key, label: map[key] }))
+      })
+    },
+    loadOverview() {
+      this.loading = true
+      api.get('/holiday/overview', { params: { year: this.year, holidayType: this.holidayType } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.rows = (res.data && res.data.rows) || []
+        })
+        .catch(err => this.showError(err, '鑺傚亣鏃ユ暟鎹姞杞藉け璐�'))
+        .finally(() => { this.loading = false })
+    },
+    loadAdjustments() {
+      this.adjustmentLoading = true
+      api.get('/holiday/adjustments', { params: { year: this.adjustmentYear } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.adjustments = res.data || []
+        })
+        .catch(err => this.showError(err, '寰�骞村浗鐪侀亾鏁版嵁璋冩暣鍔犺浇澶辫触'))
+        .finally(() => { this.adjustmentLoading = false })
+    },
+    openAdjustmentDialog(row) {
+      const source = row || {}
+      this.adjustmentForm = {
+        id: source.id || null,
+        year: source.year || this.adjustmentYear,
+        flowDate: source.flowDate || '',
+        originalValue: source.originalValue === undefined || source.originalValue === null
+          ? null : Number(source.originalValue),
+        adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
+          ? null : Number(source.adjustedValue),
+        reason: source.reason || ''
+      }
+      this.adjustmentDialogVisible = true
+      this.loadAdjustmentOriginalValue()
+    },
+    onAdjustmentYearChange() {
+      this.adjustmentOriginalRequestId += 1
+      this.adjustmentOriginalLoading = false
+      this.adjustmentForm.flowDate = ''
+      this.adjustmentForm.originalValue = null
+    },
+    onAdjustmentDateChange() {
+      this.loadAdjustmentOriginalValue()
+    },
+    loadAdjustmentOriginalValue() {
+      const year = this.adjustmentForm.year
+      const flowDate = this.adjustmentForm.flowDate
+      const requestId = ++this.adjustmentOriginalRequestId
+      this.adjustmentForm.originalValue = null
+      if (!year || !flowDate) {
+        this.adjustmentOriginalLoading = false
+        return
+      }
+      this.adjustmentOriginalLoading = true
+      api.get('/holiday/adjustments/source-value', { params: { year: year, flowDate: flowDate } })
+        .then(res => {
+          if (requestId !== this.adjustmentOriginalRequestId) return
+          if (res.code !== 200) throw new Error(res.message || '璋冩暣鍓嶅�煎姞杞藉け璐�')
+          this.adjustmentForm.originalValue = res.data === null || res.data === undefined || res.data === ''
+            ? null : Number(res.data)
+        })
+        .catch(err => {
+          if (requestId !== this.adjustmentOriginalRequestId) return
+          this.adjustmentForm.originalValue = null
+          this.showError(err, '璋冩暣鍓嶅�煎姞杞藉け璐�')
+        })
+        .finally(() => {
+          if (requestId === this.adjustmentOriginalRequestId) this.adjustmentOriginalLoading = false
+        })
+    },
+    saveAdjustment() {
+      const form = this.adjustmentForm
+      if (!form.year || !form.flowDate) {
+        this.$message.warning('璇烽�夋嫨骞翠唤鍜屾棩鏈�')
+        return
+      }
+      if (form.adjustedValue === null || form.adjustedValue === undefined || Number(form.adjustedValue) < 0) {
+        this.$message.warning('璇疯緭鍏ヤ笉浣庝簬 0 鐨勮皟鏁村悗杞︽祦閲�')
+        return
+      }
+      if (!form.reason || !form.reason.trim()) {
+        this.$message.warning('璇峰~鍐欒皟鏁村師鍥�')
+        return
+      }
+      this.adjustmentSaving = true
+      api.post('/holiday/adjustments/save', {
+        id: form.id,
+        year: form.year,
+        flowDate: form.flowDate,
+        adjustedValue: form.adjustedValue,
+        reason: form.reason
+      })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '淇濆瓨澶辫触')
+          this.adjustmentDialogVisible = false
+          this.adjustmentYear = form.year
+          this.loadAdjustments()
+          this.loadOverview()
+          this.$message.success('璋冩暣宸蹭繚瀛�')
+        })
+        .catch(err => this.showError(err, '淇濆瓨澶辫触'))
+        .finally(() => { this.adjustmentSaving = false })
+    },
+    deleteAdjustment(row) {
+      this.$confirm('纭畾鍒犻櫎 ' + row.flowDate + ' 鐨勫浗鐪侀亾鏁版嵁璋冩暣鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+        .then(() => api.post('/holiday/adjustments/delete', null, { params: { id: row.id } }))
+        .then(res => {
+          if (res && res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+          this.loadAdjustments()
+          this.loadOverview()
+          this.$message.success('璋冩暣宸插垹闄�')
+        })
+        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '鍒犻櫎澶辫触') })
+    },
+    onFileChange(file) {
+      this.selectedFile = file.raw
+      this.fileList = [file]
+      this.importMessage = ''
+    },
+    onFileRemove() {
+      this.selectedFile = null
+      this.fileList = []
+    },
+    importData(allowHistoricalOverwrite) {
+      if (!this.selectedFile) return
+      const allowOverwrite = allowHistoricalOverwrite === true || this.allowHistoricalOverwrite === true
+      const form = new FormData()
+      form.append('file', this.selectedFile)
+      form.append('year', this.year)
+      form.append('holidayType', this.holidayType)
+      form.append('allowHistoricalOverwrite', allowOverwrite ? 'true' : 'false')
+      this.importing = true
+      this.importMessage = ''
+      api.post('/holiday/import', form, { timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '瀵煎叆澶辫触')
+          const data = res.data || {}
+          if (data.confirmationRequired) {
+            const changes = data.historicalChanges || []
+            this.importMessage = ['妫�娴嬪埌鍘嗗彶鏃ユ湡鐨勬暟鎹彂鐢熷彉鍖栵紝灏氭湭瀵煎叆銆傝纭鏄惁鍏佽瑕嗙洊锛�'].concat(changes).join('\n')
+            this.$message.warning('妫�娴嬪埌鍘嗗彶鏁版嵁鍙戠敓鍙樺寲锛岃纭鏄惁鍏佽瀵煎叆')
+            this.confirmHistoricalImport(changes)
+            return
+          }
+          const lines = [
+            '瀵煎叆瀹屾垚锛氬娴� ' + (data.passengerRows || 0) + ' 鏉★紝杞︽祦 ' + (data.vehicleRows || 0) + ' 鏉★紝璺宠繃绌虹櫧 ' + (data.skippedRows || 0) + ' 鏉°��'
+          ]
+          if (data.errors && data.errors.length) {
+            lines.push('澶辫触鏄庣粏锛�')
+            data.errors.forEach(item => lines.push(item))
+          }
+          this.importMessage = lines.join('\n')
+          this.allowHistoricalOverwrite = false
+          this.$message.success('瀵煎叆瀹屾垚')
+          this.loadOverview()
+        })
+        .catch(err => {
+          this.allowHistoricalOverwrite = false
+          this.showError(err, '瀵煎叆澶辫触')
+        })
+        .finally(() => { this.importing = false })
+    },
+    confirmHistoricalImport(changes) {
+      const rows = (changes && changes.length ? changes : ['鍘嗗彶鏁版嵁鍙戠敓鍙樺寲']).map(this.escapeHtml)
+      const html = '<div>妫�娴嬪埌宸插彂甯冩棩鏈熺殑鏁版嵁鍙戠敓鍙樺寲銆傚厑璁稿鍏ュ皢瑕嗙洊杩欎簺鍘嗗彶鏁版嵁銆�</div>'
+        + '<pre style="max-height:320px;overflow:auto;white-space:pre-wrap;margin-top:10px;background:#f7f7f7;padding:8px;border:1px solid #e4e7ed;">'
+        + rows.join('\n') + '</pre>'
+      this.$confirm(html, '鍘嗗彶鏁版嵁鍙戠敓鍙樺寲', {
+        dangerouslyUseHTMLString: true,
+        confirmButtonText: '鍏佽瀵煎叆',
+        cancelButtonText: '涓嶅厑璁�',
+        type: 'warning',
+        distinguishCancelAndClose: true
+      }).then(() => {
+        this.allowHistoricalOverwrite = true
+        this.importData(true)
+      }).catch(() => {
+        this.allowHistoricalOverwrite = false
+      })
+    },
+    escapeHtml(value) {
+      return String(value === null || value === undefined ? '' : value)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#39;')
+    },
+    exportData() {
+      this.exporting = true
+      api.get('/holiday/export', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 120000 })
+        .then(res => { this.downloadBlob(res, this.year + '骞�' + this.holidayTypeLabel + '鏁版嵁.xlsx'); this.$message.success('瀵煎嚭鎴愬姛') })
+        .catch(err => this.handleBlobError(err, '瀵煎嚭澶辫触'))
+        .finally(() => { this.exporting = false })
+    },
+    downloadTemplate() {
+      const templatePath = '涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx'
+      const encodedPath = templatePath.split('/').map(encodeURIComponent).join('/')
+      api.get('/templates/' + encodedPath, { responseType: 'blob' })
+        .then(res => { this.downloadBlob(res, '2026骞村崄涓�鏁版嵁.xlsx'); this.$message.success('妯℃澘涓嬭浇鎴愬姛') })
+        .catch(err => this.handleBlobError(err, '妯℃澘涓嬭浇澶辫触'))
+    },
+    downloadBulletin() {
+      this.bulletinLoading = true
+      api.get('/holiday/bulletin', { params: { date: this.bulletinDate }, responseType: 'blob', timeout: 120000 })
+        .then(res => {
+          const day = Number((this.bulletinDate || '').slice(-2))
+          this.downloadBlob(res, '婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ワ紙10鏈�' + day + '鏃ワ級.docx')
+          this.$message.success('蹇姤涓嬭浇鎴愬姛')
+        })
+        .catch(err => this.handleBlobError(err, '蹇姤鐢熸垚澶辫触'))
+        .finally(() => { this.bulletinLoading = false })
+    },
+    downloadTrend() {
+      this.trendLoading = true
+      api.get('/holiday/trend', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 180000 })
+        .then(res => {
+          this.downloadBlob(res, this.year + '骞�' + this.holidayTypeLabel + '鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx')
+          this.$message.success('璧板娍鍥句笅杞芥垚鍔�')
+        })
+        .catch(err => this.handleBlobError(err, '璧板娍鍥剧敓鎴愬け璐�'))
+        .finally(() => { this.trendLoading = false })
+    },
+    downloadBlob(data, fileName) {
+      const blob = data instanceof Blob ? data : new Blob([data])
+      const url = window.URL.createObjectURL(blob)
+      const link = document.createElement('a')
+      link.href = url
+      link.download = fileName
+      document.body.appendChild(link)
+      link.click()
+      document.body.removeChild(link)
+      window.URL.revokeObjectURL(url)
+    },
+    handleBlobError(err, fallback) {
+      if (err && err.response && err.response.data instanceof Blob) {
+        err.response.data.text().then(text => {
+          try {
+            const parsed = JSON.parse(text)
+            this.$message.error(parsed.message || fallback)
+          } catch (e) {
+            this.$message.error(fallback)
+          }
+        })
+        return
+      }
+      this.showError(err, fallback)
+    },
+    showError(err, fallback) {
+      if (err && err._toast) return
+      const data = err && err.response && err.response.data
+      this.$message.error((data && data.message) || (err && err.message) || fallback)
+    },
+    onObservationFileChange(file) {
+      this.observationFile = file.raw
+      this.observationFileList = [file]
+      this.observationMessage = ''
+    },
+    onObservationFileRemove() {
+      this.observationFile = null
+      this.observationFileList = []
+    },
+    loadObservationDates() {
+      this.observationLoading = true
+      api.get('/observation/dates')
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.observationDates = res.data || []
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹姞杞藉け璐�'))
+        .finally(() => { this.observationLoading = false })
+    },
+    importObservation() {
+      if (!this.observationFile) return
+      const form = new FormData()
+      form.append('file', this.observationFile)
+      this.observationImporting = true
+      this.observationMessage = ''
+      api.post('/observation/import', form, { timeout: 300000, headers: { 'Content-Type': 'multipart/form-data' } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '瀵煎叆澶辫触')
+          const data = res.data || {}
+          const dates = (data.dates && data.dates.length) ? data.dates.join('銆�') : '鈥�'
+          const lines = ['瀵煎叆瀹屾垚锛�' + (data.totalRows || 0) + ' 鏉★紝瑙傛祴绔� ' + (data.stationCount || 0)
+            + ' 涓紝鏃ユ湡 ' + dates + '锛岃烦杩囩┖鐧� ' + (data.skippedRows || 0) + ' 鏉°��']
+          if (data.errors && data.errors.length) {
+            lines.push('澶辫触鏄庣粏锛�')
+            data.errors.forEach(item => lines.push(item))
+          }
+          this.observationMessage = lines.join('\n')
+          this.$message.success('瑙傛祴绔欐暟鎹鍏ュ畬鎴�')
+          this.observationFile = null
+          this.observationFileList = []
+          this.loadObservationDates()
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹鍏ュけ璐�'))
+        .finally(() => { this.observationImporting = false })
+    },
+    viewObservation(date) {
+      this.observationDialogDate = date
+      this.observationDialogVisible = true
+      this.observationRowsLoading = true
+      this.observationRows = []
+      api.get('/observation/rows', { params: { date: date, limit: 2000 } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.observationRows = res.data || []
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐槑缁嗗姞杞藉け璐�'))
+        .finally(() => { this.observationRowsLoading = false })
+    },
+    deleteObservation(date) {
+      this.$confirm('纭畾鍒犻櫎 ' + date + ' 鐨勮娴嬬珯鏁版嵁鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+        .then(() => api.post('/observation/delete', null, { params: { date: date } }))
+        .then(res => {
+          if (res && res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+          this.$message.success('宸插垹闄�')
+          this.loadObservationDates()
+        })
+        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '鍒犻櫎澶辫触') })
+    },
+    formatAdjustmentValue(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return number.toFixed(4)
+    },
+    formatOriginalAdjustmentValue(value) {
+      if (value === null || value === undefined || value === '') return ''
+      return this.formatAdjustmentValue(value)
+    },
+    formatDateTime(value) {
+      if (!value) return '鈥�'
+      return String(value).replace('T', ' ').slice(0, 19)
+    },
+    formatNumber(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      return Number(value).toFixed(2)
+    },
+    formatWanVehicles(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return (number / 10000).toFixed(4)
+    },
+    formatInteger(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return String(Math.round(number))
+    },
+    formatPlain(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return String(number)
+    },
+    formatCoefficient(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
+    },
+    formatRatio(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return (number * 100).toFixed(2) + '%'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
+.operation-card { margin-top: 12px; }
+.operation-row { display: flex; gap: 36px; align-items: flex-start; flex-wrap: wrap; }
+.operation-item { display: flex; align-items: center; gap: 8px; }
+.operation-item .label { color: #606266; font-size: 14px; }
+.import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
+.import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
+.hint { margin-left: 10px; color: #909399; font-size: 12px; }
+.danger-text { color: #f56c6c; }
+</style>

--
Gitblit v1.9.1