| | |
| | | </div> |
| | | </el-alert> |
| | | </div> |
| | | <el-dialog :title="'预览数据 - ' + previewModule + '(' + previewPeriod + ')'" :visible.sync="previewVisible" width="760px"> |
| | | <el-dialog :title="'预览数据 - ' + previewModule + '(' + previewPeriod + ')'" :visible.sync="previewVisible" width="760px" |
| | | top="5vh" custom-class="pv-dialog" v-dialog-drag> |
| | | <div class="preview-body"> |
| | | <div v-for="it in previewItems" :key="it.key" class="preview-item"> |
| | | <div class="preview-head"> |
| | | <span class="preview-name">{{ it.label }}</span> |
| | | <span class="preview-rows">共 {{ it.rowCount || 0 }} 行</span> |
| | | <span class="preview-rows" v-if="it.key === 'summaryWorkbook'">指标 {{ (it.summary || []).length }} 项</span> |
| | | <span class="preview-rows" v-else>共 {{ it.rowCount || 0 }} 行明细</span> |
| | | </div> |
| | | <div class="preview-coverage"> |
| | | <span class="cov-label">月度覆盖:</span> |
| | |
| | | </div> |
| | | <table class="preview-summary wb-table" v-if="it.key === 'summaryWorkbook' && it.summary && it.summary.length"> |
| | | <thead> |
| | | <tr><th style="width:44%">指标</th><th style="width:30%">数据</th><th>同比</th></tr> |
| | | <tr><th style="width:12%">分类</th><th style="width:40%">指标</th><th style="width:28%">数据</th><th>同比</th></tr> |
| | | </thead> |
| | | <tbody> |
| | | <template v-for="(sec, si) in wbRows(it.summary)"> |
| | |
| | | <div v-if="it.key === 'summaryWorkbook' && wbMissing(it.summary)" class="wb-note"> |
| | | 部分指标缺少上年同期({{ wbPrevYear() }})基期数据,同比暂未计算 |
| | | </div> |
| | | <table class="preview-summary" v-else-if="it.summary && it.summary.length"> |
| | | <table class="preview-summary" v-if="it.key !== 'summaryWorkbook' && it.summary && it.summary.length"> |
| | | <tbody> |
| | | <tr v-for="(s, i) in it.summary" :key="i"> |
| | | <td>{{ s.label }}</td> |
| | |
| | | <span slot="footer"> |
| | | <el-button size="small" @click="noteDialogVisible = false">取消</el-button> |
| | | <el-button size="small" type="primary" @click="saveNote">保存</el-button> |
| | | </span> |
| | | </el-dialog> |
| | | |
| | | <!-- 汇总大表异步生成进度(生成一次约 10~20 秒,避免点击后长时间无响应) --> |
| | | <el-dialog :title="taskDialogTitle" :visible.sync="taskVisible" width="460px" |
| | | :close-on-click-modal="false" :show-close="taskFinished"> |
| | | <div class="task-body"> |
| | | <el-progress :percentage="taskInfo.percent || 0" :status="taskInfo.status === 'FAILED' ? 'exception' : (taskDone ? 'success' : null)" |
| | | :stroke-width="14"></el-progress> |
| | | <p class="task-step">{{ taskInfo.step || '排队中' }}</p> |
| | | <p class="task-meta" v-if="taskInfo.period && !taskInfo.fromCache">报表期 {{ taskInfo.period }} 已用 {{ ((taskInfo.elapsedMs || 0) / 1000).toFixed(1) }} 秒</p> |
| | | <p class="task-meta" v-else-if="taskInfo.period">报表期 {{ taskInfo.period }} 上次生成于 {{ taskCacheTime }}</p> |
| | | <p class="task-meta" v-if="taskInfo.fromCache">本次直接沿用 {{ taskCacheTime }} 生成的结果(期间没有新的数据导入)。如需按最新数据重算,请点「重新生成」。</p> |
| | | <p class="task-done" v-if="taskDownloaded"> |
| | | 已自动下载:<b>{{ taskFileName }}</b><span v-if="taskFileSize">({{ taskFileSizeText }})</span>,文件在浏览器默认下载目录。 |
| | | </p> |
| | | <p class="task-error" v-else-if="taskDone">自动下载没成功,可点「保存文件」再存一次。</p> |
| | | <p class="task-error" v-if="taskInfo.status === 'FAILED'">{{ taskInfo.error }}</p> |
| | | </div> |
| | | <span slot="footer"> |
| | | <el-button size="small" v-if="taskDone && !taskDownloaded" @click="downloadTaskFile()">保存文件</el-button> |
| | | <el-button size="small" :disabled="taskRunning" @click="restartTask">重新生成</el-button> |
| | | <el-button size="small" v-if="taskRunning" @click="minimizeTask">后台运行</el-button> |
| | | <el-button size="small" v-else type="primary" @click="closeTask">关闭</el-button> |
| | | </span> |
| | | </el-dialog> |
| | | </el-main> |
| | |
| | | noteDialogVisible: false, |
| | | noteEditingKey: '', |
| | | noteDraft: '', |
| | | // 汇总大表异步生成任务 |
| | | taskVisible: false, |
| | | taskInfo: {}, |
| | | taskTimer: null, |
| | | // 进度弹窗:完成后会自动下载一次,这里记录「是否已下载」与文件信息(已下载就不再显示下载按钮) |
| | | taskDownloaded: false, |
| | | taskFileName: '', |
| | | taskFileSize: 0, |
| | | taskKey: 'summaryWorkbook', |
| | | modules: [ |
| | | { |
| | | key: 'summary', title: '汇总大表', items: [ |
| | |
| | | { key: 'investPlan', label: '全省预安排计划进度汇总', fileName: '生成_全省预安排计划进度情况汇总.xls', status: null, statusNote: '' }, |
| | | { key: 'investFiveYearLogistics', label: '十五五规划物流项目进展', fileName: '生成_十五五规划物流项目进展情况.xls', status: null, statusNote: '' }, |
| | | { key: 'investBillion', label: '亿元投资项目', fileName: '生成_湖北省(客货站场)亿元投资项目.xls', status: null, statusNote: '' }, |
| | | { key: 'investCounty', label: '经济强县项目投资统计', fileName: '生成_经济强县交通物流基础设施投资统计报表.xls', status: null, statusNote: '' } |
| | | { key: 'investCounty', label: '经济强县项目投资统计', fileName: '生成_经济强县交通物流基础设施投资统计报表.xls', status: null, statusNote: '' }, |
| | | { key: 'investPassengerDetail', label: '客运站投资明细表', fileName: '生成_X月客运站投资明细表.xlsx', status: null, statusNote: '' }, |
| | | { key: 'investLogisticsDetail', label: '物流站场投资明细', fileName: '生成_X月物流站场投资明细.xls', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, |
| | | { |
| | |
| | | options: months.map(mo => ({ label: y + '年' + mo + '月', value: y + '-' + String(mo).padStart(2, '0') })) |
| | | })) |
| | | }, |
| | | // ===== 以下为汇总大表异步生成弹窗的派生状态(必须并入同一个 computed,另起同名 key 会覆盖掉上面的计算属性)===== |
| | | taskRunning() { |
| | | const s = this.taskInfo && this.taskInfo.status |
| | | return s === 'PENDING' || s === 'RUNNING' |
| | | }, |
| | | taskDone() { |
| | | return this.taskInfo && this.taskInfo.status === 'DONE' |
| | | }, |
| | | // 生成结束(成功或失败)后才允许收起弹窗 |
| | | taskFinished() { |
| | | const s = this.taskInfo && this.taskInfo.status |
| | | return s === 'DONE' || s === 'FAILED' |
| | | }, |
| | | taskCacheTime() { |
| | | const t = this.taskInfo && this.taskInfo.finishedAt |
| | | if (!t) return '上次' |
| | | const d = new Date(t) |
| | | const p = (n) => String(n).padStart(2, '0') |
| | | return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) |
| | | }, |
| | | // 弹窗标题随任务状态变化,避免「已完成」还写着「正在生成」 |
| | | taskDialogTitle() { |
| | | const s = this.taskInfo && this.taskInfo.status |
| | | if (s === 'FAILED') return '汇总大表生成失败' |
| | | if (s === 'DONE') return '汇总大表已生成' |
| | | return '正在生成汇总大表' |
| | | }, |
| | | taskFileSizeText() { |
| | | const n = this.taskFileSize || 0 |
| | | if (!n) return '' |
| | | return n >= 1048576 ? (n / 1048576).toFixed(2) + ' MB' : Math.round(n / 1024) + ' KB' |
| | | } |
| | | }, |
| | | watch: { |
| | | activeKey() { |
| | |
| | | this.activeKey = (mine.length ? mine : this.visibleModules.map(m => m.key))[0] |
| | | this.ensureCycle() |
| | | this.refreshAll() |
| | | }, methods: { |
| | | }, |
| | | beforeDestroy() { |
| | | this.stopTaskPolling() |
| | | }, |
| | | methods: { |
| | | moduleTitle(key) { |
| | | const m = this.modules.find(x => x.key === key) |
| | | return m ? m.title : key |
| | |
| | | this.previewVisible = true |
| | | this.loading = '' |
| | | }) |
| | | .catch(err => this.failWith(err)) |
| | | .catch(err => this.failWith(err, '预览')) |
| | | }, |
| | | fmtVal(v) { |
| | | if (v === null || v === undefined || v === '') return '—' |
| | |
| | | { |
| | | name: '道路货运', |
| | | rows: mk([ |
| | | ['当月货运量(万吨)', '全省货运量·当月'], |
| | | ['当月货物周转量(万吨)', '全省货物周转量·当月', '全省货物周转量·当月同比'], |
| | | ['累计货运量(万吨)', '全省货运量·累计'], |
| | | ['当月货运量(万吨)', '全省货运量·当月', '全省货运量·当月同比'], |
| | | ['当月货物周转量(万吨公里)', '全省货物周转量·当月', '全省货物周转量·当月同比'], |
| | | ['累计货运量(万吨)', '全省货运量·累计', '全省货运量·累计同比'], |
| | | ['累计货物周转量(万吨公里)', '全省货物周转量·累计', '全省货物周转量·累计同比'] |
| | | ]) |
| | | }, |
| | | { |
| | | name: '城市客运', |
| | | rows: mk([ |
| | | ['当月城市客运量:', '城市客运量·当月'], |
| | | ['当月城市客运周转量', '城市客运周转量·当月'], |
| | | ['累计城市客运量:', '城市客运量·累计'], |
| | | ['累计城市客运周转量', '城市客运周转量·累计'] |
| | | ['当月城市客运量', '城市客运量·当月', '城市客运量·当月同比'], |
| | | ['当月城市客运周转量', '城市客运周转量·当月', '城市客运周转量·当月同比'], |
| | | ['累计城市客运量', '城市客运量·累计', '城市客运量·累计同比'], |
| | | ['累计城市客运周转量', '城市客运周转量·累计', '城市客运周转量·累计同比'] |
| | | ]) |
| | | }, |
| | | { |
| | | name: '中口径', |
| | | rows: mk([ |
| | | ['当月中口径客运量', '中口径客运量·当月'], |
| | | ['当月中口径周转量', '中口径周转量·当月'], |
| | | ['累计中口径客运量', '中口径客运量·累计'], |
| | | ['累计中口径周转量', '中口径周转量·累计'] |
| | | ['当月中口径客运量', '中口径客运量·当月', '中口径客运量·当月同比'], |
| | | ['当月中口径周转量', '中口径周转量·当月', '中口径周转量·当月同比'], |
| | | ['累计中口径客运量', '中口径客运量·累计', '中口径客运量·累计同比'], |
| | | ['累计中口径周转量', '中口径周转量·累计', '中口径周转量·累计同比'] |
| | | ]) |
| | | } |
| | | ] |
| | |
| | | if (continuous) return '1~' + cov[cov.length - 1] + '月均有数据' |
| | | return cov.map(x => x + '月').join('、') + ' 有数据' |
| | | }, |
| | | // ==================== 汇总大表异步生成(进度弹窗) ==================== |
| | | // 后端 ReportExportTaskService:提交任务 -> 轮询进度 -> 完成后下载文件。 |
| | | // 同报表期 10 分钟内且期间没有新导入数据时,后端直接返回上次结果(弹窗内会说明来源时间)。 |
| | | startSummaryTask(refresh) { |
| | | if (!this.period) { this.$message.warning('请先选择周期和月份'); return } |
| | | this.stopTaskPolling() |
| | | this.taskInfo = { status: 'PENDING', percent: 0, step: '提交任务', period: this.period, elapsedMs: 0 } |
| | | this.taskVisible = true |
| | | this.taskDownloaded = false |
| | | this.taskFileName = '' |
| | | this.taskFileSize = 0 |
| | | this.loading = 'gen-' + this.curModule.key |
| | | const params = Object.assign({}, this.buildParams(), { refresh: refresh ? 'true' : 'false' }) |
| | | api.get('/report/exportTask/summaryWorkbook', { params, timeout: 60000 }) |
| | | .then(res => { |
| | | // 后端统一返回 Result:code 非 200 时 data 为空,必须提示而不是空转 |
| | | if (!res || res.code !== 200 || !res.data || !res.data.taskId) { |
| | | this.taskInfo = { status: 'FAILED', percent: 0, step: '提交失败', period: this.period, elapsedMs: 0, |
| | | error: (res && res.message) || '后端未返回任务号' } |
| | | this.loading = '' |
| | | return |
| | | } |
| | | this.taskInfo = res.data |
| | | if (this.taskInfo.status === 'DONE') { this.loading = ''; this.downloadTaskFile(true); return } |
| | | if (this.taskInfo.status === 'FAILED') { this.loading = ''; return } |
| | | this.pollTask() |
| | | }) |
| | | .catch(err => { |
| | | this.stopTaskPolling() |
| | | this.loading = '' |
| | | this.taskVisible = false |
| | | this.failWith(err, '生成') |
| | | }) |
| | | }, |
| | | pollTask() { |
| | | this.stopTaskPolling() |
| | | const id = this.taskInfo && this.taskInfo.taskId |
| | | if (!id) return |
| | | this.taskTimer = setInterval(() => { |
| | | api.get('/report/exportTask/' + id, { timeout: 30000 }) |
| | | .then(res => { |
| | | if (!res || res.code !== 200 || !res.data) { |
| | | // 任务被清理/过期:停止轮询并给出原因,不让弹窗一直空转 |
| | | this.stopTaskPolling() |
| | | this.loading = '' |
| | | this.taskInfo = Object.assign({}, this.taskInfo, { |
| | | status: 'FAILED', step: '任务已失效', |
| | | error: (res && res.message) || '导出任务已失效,请重新生成' |
| | | }) |
| | | return |
| | | } |
| | | this.taskInfo = res.data |
| | | if (this.taskInfo.status === 'DONE') { |
| | | this.stopTaskPolling() |
| | | this.loading = '' |
| | | this.downloadTaskFile(true) |
| | | } else if (this.taskInfo.status === 'FAILED') { |
| | | this.stopTaskPolling() |
| | | this.loading = '' |
| | | if (!this.taskVisible) this.$message.error('汇总大表生成失败:' + (this.taskInfo.error || '未知原因')) |
| | | } |
| | | }) |
| | | .catch(() => { /* 单次轮询失败忽略,下一轮继续 */ }) |
| | | }, 700) |
| | | }, |
| | | stopTaskPolling() { |
| | | if (this.taskTimer) { clearInterval(this.taskTimer); this.taskTimer = null } |
| | | }, |
| | | // auto=true 表示「任务完成后的自动下载」;用户点按钮时为 false |
| | | downloadTaskFile(auto) { |
| | | const id = this.taskInfo && this.taskInfo.taskId |
| | | if (!id) return |
| | | const name = this.taskInfo.fileName || ('生成_道路运输量汇总表_' + this.period + '.xlsx') |
| | | this.taskFileName = name |
| | | api.get('/report/exportTask/' + id + '/file', { responseType: 'blob', timeout: 120000 }) |
| | | .then(res => { |
| | | this.taskFileSize = (res && res.size) || 0 |
| | | this.taskDownloaded = true |
| | | this.downloadBlob(res, name) |
| | | this.$message.success(auto ? '生成完成,已自动下载' : '报表导出成功') |
| | | }) |
| | | .catch(err => this.failWith(err)) |
| | | }, |
| | | restartTask() { this.startSummaryTask(true) }, |
| | | closeTask() { this.taskVisible = false; this.stopTaskPolling(); this.loading = '' }, |
| | | // 生成中收起弹窗:任务继续在后台执行,完成后仍会自动下载(与原来同步导出的「点完即下载」一致) |
| | | minimizeTask() { |
| | | this.taskVisible = false |
| | | this.$message.info('已转入后台生成,完成后会自动下载') |
| | | }, |
| | | exportChecked(itemList) { |
| | | const m = this.curModule |
| | | if (!this.period) { this.$message.warning('请先选择周期和月份'); return } |
| | |
| | | const params = this.buildParams() |
| | | const doExport = () => { |
| | | if (items.length === 1) { |
| | | // 汇总大表生成一次约 10~20 秒,走异步任务(进度弹窗),避免点击后长时间无响应 |
| | | if (items[0].key === 'summaryWorkbook') { this.startSummaryTask(false); return } |
| | | api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 }) |
| | | .then(res => { this.downloadBlob(res, items[0].fileName); this.$message.success('报表导出成功'); this.loading = '' }) |
| | | .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' }) |
| | | .catch(err => this.failWith(err)) |
| | | return |
| | | } |
| | |
| | | }) |
| | | .catch(() => doExport()) |
| | | }, |
| | | failWith(err) { |
| | | // 预览/导出失败统一提示:区分“连不上服务器”与“后端返回的错误原因”。 |
| | | // 导出接口用 responseType: 'blob',报错时 data 也是 Blob,必须先读文本再解析, |
| | | // 否则拿不到后端真实原因,只会显示笼统的“请确认已导入对应数据”。 |
| | | failWith(err, action) { |
| | | if (err && err._toast) { this.loading = ''; return } |
| | | let msg = '' |
| | | const d = err && err.response && err.response.data |
| | | if (d && typeof d === 'object' && d.message) msg = d.message |
| | | this.$message.error('导出失败:' + (msg || '请确认已导入对应数据,或查看后端日志')) |
| | | this.loading = '' |
| | | const act = action || '导出' |
| | | const finish = (reason) => { |
| | | this.$message.error(act + '失败:' + reason) |
| | | this.loading = '' |
| | | } |
| | | if (!err || !err.response) { |
| | | if (err && err.code === 'ECONNABORTED') { finish('请求超时,请稍后重试'); return } |
| | | finish('无法连接服务器,请检查网络连接,或确认后端服务是否已启动') |
| | | return |
| | | } |
| | | const d = err.response.data |
| | | const fallback = '请确认已导入对应数据,或查看后端日志' |
| | | if (d && typeof d.text === 'function') { |
| | | Promise.resolve(d.text()).then(txt => finish(this.errReason(txt) || fallback)).catch(() => finish(fallback)) |
| | | return |
| | | } |
| | | finish(this.errReason(typeof d === 'string' ? d : (d && d.message)) || fallback) |
| | | }, |
| | | // 后端异常响应可能是 JSON/HTML/纯文本,尽量提取出可读的原因 |
| | | errReason(raw) { |
| | | if (!raw) return '' |
| | | let text = String(raw).trim() |
| | | if (text.charAt(0) === '{' || text.charAt(0) === '[') { |
| | | try { |
| | | const j = JSON.parse(text) |
| | | if (j && j.message) return j.message |
| | | } catch (e) { /* 非 JSON,按纯文本处理 */ } |
| | | } |
| | | text = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() |
| | | // 开发代理/网络层报错转成人话,避免直接把 ECONNREFUSED 之类抛给用户 |
| | | if (/ECONNREFUSED|ECONNRESET|Proxy error|socket hang up|Network Error|Failed to fetch/i.test(text)) { |
| | | return '无法连接服务器,请检查网络连接,或确认后端服务是否已启动' |
| | | } |
| | | return text.length > 200 ? text.slice(0, 200) + '…' : text |
| | | }, |
| | | // 文件名占位符 X月 -> 当前报表期月份(如「生成_X月客运站投资明细表.xlsx」->「生成_8月客运站投资明细表.xlsx」) |
| | | fileNameOf(item) { |
| | | const name = (item && item.fileName) || '' |
| | | const mm = parseInt((this.period || '').split('-')[1], 10) |
| | | if (!mm) return name |
| | | return name.replace(/X月/g, mm + '月') |
| | | }, |
| | | downloadBlob(res, fileName) { |
| | | const isZip = /\.zip$/.test(fileName) |
| | |
| | | .wb-yoy { text-align:right; color:#606266; } |
| | | .wb-yoy.yoy-empty { color:#C0C4CC; } |
| | | .wb-note { color:#E6A23C; font-size:12px; margin:2px 0 8px; } |
| | | /* 异步生成进度弹窗 */ |
| | | .task-body { padding: 4px 2px; } |
| | | .task-step { margin: 12px 0 4px; font-size: 14px; color:#303133; } |
| | | .task-meta { margin: 2px 0; font-size: 12px; color:#909399; line-height: 1.6; } |
| | | .task-error { margin: 8px 0 0; font-size: 13px; color:#F56C6C; word-break: break-all; } |
| | | .task-done { margin: 8px 0 0; font-size: 13px; color:#67C23A; line-height: 1.6; } |
| | | </style> |
| | | |
| | | <style> |
| | | /* 预览数据弹窗:圆角 + 可通过标题栏拖动(v-dialog-drag 指令)。 |
| | | 不能写进 scoped:el-dialog 默认 append-to-body,DOM 挂在 body 上,scoped 选择器匹配不到。 */ |
| | | .pv-dialog { border-radius: 10px; overflow: hidden; } |
| | | .pv-dialog .el-dialog__header { cursor: move; user-select: none; -webkit-user-select: none; } |
| | | .pv-dialog .el-dialog__headerbtn { cursor: pointer; } |
| | | </style> |