xyc
2 天以前 df37f961e9101ce2bbc22848d7d80d8170b6ffbf
traffic-audit-web/src/views/HolidayStat.vue
@@ -52,6 +52,10 @@
                <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>
@@ -77,6 +81,129 @@
              <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="230" 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="120px">
                <el-form-item label="年份">
                  <el-select v-model="adjustmentForm.year" style="width:100%" @change="adjustmentForm.flowDate = ''">
                    <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="选择日期"></el-date-picker>
                </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>
@@ -101,22 +228,56 @@
      importing: false,
      exporting: false,
      bulletinLoading: false,
      trendLoading: false,
      selectedFile: null,
      fileList: [],
      importMessage: '',
      allowHistoricalOverwrite: false,
      bulletinDate: this.previousDateString()
      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,
      adjustmentForm: {
        id: null,
        year: new Date().getFullYear() - 1,
        flowDate: '',
        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() {
@@ -141,6 +302,72 @@
        })
        .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 || '',
        adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
          ? null : Number(source.adjustedValue),
        reason: source.reason || ''
      }
      this.adjustmentDialogVisible = true
    },
    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
@@ -241,6 +468,16 @@
        .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)
@@ -271,9 +508,105 @@
      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)
    },
    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 '—'
@@ -299,4 +632,6 @@
.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>