<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">企业解释</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>
|
</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>
|
</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: [2024, 2025, 2026, 2027, 2028, 2029, 2030],
|
year: 2026,
|
holidayType: 'NATIONAL_DAY',
|
holidayTypeOptions: [],
|
rows: [],
|
loading: false,
|
importing: false,
|
exporting: false,
|
bulletinLoading: false,
|
selectedFile: null,
|
fileList: [],
|
importMessage: '',
|
allowHistoricalOverwrite: false,
|
bulletinDate: this.previousDateString()
|
}
|
},
|
computed: {
|
holidayTypeLabel() {
|
const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
|
return item ? item.label : this.holidayType
|
}
|
},
|
mounted() {
|
this.loadOptions()
|
this.loadOverview()
|
},
|
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 })
|
},
|
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, '&')
|
.replace(/</g, '<')
|
.replace(/>/g, '>')
|
.replace(/"/g, '"')
|
.replace(/'/g, ''')
|
},
|
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 })
|
},
|
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)
|
},
|
formatNumber(value) {
|
if (value === null || value === undefined || value === '') return '—'
|
return Number(value).toFixed(2)
|
},
|
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; }
|
</style>
|