| | |
| | | <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="/report"> |
| | | <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-aside> |
| | | <el-main> |
| | | <div class="page-scroll"> |
| | | <h3>生成统计报表</h3> |
| | | <el-card style="max-width:640px"> |
| | | <el-form label-width="100px"> |
| | | <el-form-item label="报表期"> |
| | | <el-date-picker v-model="period" type="month" placeholder="选择月份" value-format="yyyy-MM" style="width:100%"></el-date-picker> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" @click="exportReport('cityDetail')" :loading="loading === 'cityDetail'">生成_货运量分市州明细</el-button> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" @click="exportReport('freightRank')" :loading="loading === 'freightRank'">生成_货运量排名</el-button> |
| | | <el-button type="primary" @click="exportReport('turnoverRank')" :loading="loading === 'turnoverRank'">生成_周转量排名</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | </el-card> |
| | | <el-alert |
| | | title="提示:报表由系统根据已导入数据自动计算生成。货运量:合计来自模板_货运量周转量(左半1-N月)、规上来自H203-2月报、规下=合计-规上;周转量来自规上规下拆分表。" |
| | | type="info" style="margin-top:15px" :closable="false"></el-alert> |
| | | <!-- 第一行:标题 + 更新时间 --> |
| | | <div class="topbar"> |
| | | <h3>生成统计报表</h3> |
| | | <div class="topbar-right"> |
| | | <span v-if="myModules.length" class="focus-user">{{ realName || username }}:负责 {{ myModules.map(moduleTitle).join('、') }}</span> |
| | | <span class="update-time">更新时间:{{ updatedAt }}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第二行:报表类型切换(货运 / 旅客 / 投资 / 能耗 / 城市客运);el-radio-group 替代 el-tabs,规避 el-tabs 布局无限循环(与 /import 修复一致) --> |
| | | <el-radio-group v-model="activeKey" class="module-tabs"> |
| | | <el-radio-button v-for="m in visibleModules" :key="m.key" :label="m.key"> |
| | | {{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag> |
| | | </el-radio-button> |
| | | </el-radio-group> |
| | | |
| | | <!-- 第三行:查询条件区(周期 + 月份 + 查询) --> |
| | | <section class="query-panel"> |
| | | <span class="ctrl-label">周期</span> |
| | | <el-radio-group v-model="cycle" size="small" @change="onCycleChange"> |
| | | <el-radio-button v-for="opt in cycleOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-radio-button> |
| | | </el-radio-group> |
| | | <span class="ctrl-label">{{ cycle === 'year' ? '年份' : '月份' }}</span> |
| | | <el-select v-model="period" size="small" style="width:190px" :placeholder="periodPlaceholder" @change="onPeriodChange"> |
| | | <el-option-group v-for="grp in periodGroups" :key="grp.label" :label="grp.label"> |
| | | <el-option v-for="opt in grp.options" :key="opt.value" :label="opt.label" :value="opt.value"></el-option> |
| | | </el-option-group> |
| | | </el-select> |
| | | <el-button type="primary" size="small" icon="el-icon-search" @click="doQuery">查询</el-button> |
| | | </section> |
| | | |
| | | <!-- 第四行:报表列表 --> |
| | | <section class="module-panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">报表列表<span class="muted">({{ curModule.title }})</span></div> |
| | | <div class="panel-controls"> |
| | | <el-checkbox :value="allChecked" @change="toggleAll">全选</el-checkbox> |
| | | <span class="checked-count">已选 {{ curModule.checked.length }} 张</span> |
| | | <el-button size="small" icon="el-icon-view" :disabled="!curModule.checked.length" @click="previewChecked()">批量预览</el-button> |
| | | <el-button size="small" type="primary" icon="el-icon-download" :disabled="!curModule.checked.length" @click="exportChecked()">批量导出</el-button> |
| | | </div> |
| | | </div> |
| | | <el-table :data="curModule.items" size="mini" class="report-table"> |
| | | <el-table-column width="44"> |
| | | <template slot-scope="{ row }"> |
| | | <el-checkbox :value="curModule.checked.indexOf(row.key) >= 0" @change="toggleChecked(row)"></el-checkbox> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="报表名称" prop="label" min-width="220"></el-table-column> |
| | | <el-table-column label="数据状态" width="180"> |
| | | <template slot-scope="{ row }"> |
| | | <el-tag v-if="row.status === 'ready'" size="mini" type="success">数据就绪</el-tag> |
| | | <el-tag v-else-if="row.status === 'unaudited'" size="mini" type="warning">未审核</el-tag> |
| | | <el-tag v-else-if="row.status === 'missing'" size="mini" type="danger">缺数据</el-tag> |
| | | <el-tag v-else size="mini" type="info">—</el-tag> |
| | | <div v-if="row.statusNote" class="status-note">{{ row.statusNote }}</div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="备注" min-width="190"> |
| | | <template slot-scope="{ row }"> |
| | | <span class="note-text">{{ noteOf(row.key) || '点击图标添加备注' }}</span> |
| | | <el-button size="mini" type="text" icon="el-icon-edit" title="编辑备注" @click="openNoteDialog(row)"></el-button> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="170" align="right"> |
| | | <template slot-scope="{ row }"> |
| | | <el-button size="mini" icon="el-icon-view" @click="previewOne(row)">预览</el-button> |
| | | <el-button size="mini" type="primary" icon="el-icon-download" @click="exportOne(row)">导出</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="panel-footer">共 {{ curModule.items.length }} 张报表</div> |
| | | </section> |
| | | |
| | | <el-alert title="使用提示" type="info" style="margin-top:15px" :closable="false"> |
| | | <div class="tip-text"> |
| | | <p>1. 选择报表类型 Tab、周期与月份后点击「查询」,列表会显示每张报表的数据状态(数据就绪 / 缺数据 / 未审核)。</p> |
| | | <p>2. 数据校验与审核请到「审核结果」页查看,规则可在「规则管理」页配置。</p> |
| | | <p>3. 单张可直接点「预览 / 导出」;多张可勾选后「批量预览 / 批量导出」,多张导出自动打包为 zip。</p> |
| | | <p>4. 月报:选 2026-07 生成 1~7 月数据;年报:选年份生成全年 1~12 月 + 上年同比。</p> |
| | | </div> |
| | | </el-alert> |
| | | </div> |
| | | <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" 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> |
| | | <span :class="it.ready ? 'cov-ok' : 'cov-bad'">{{ coverageText(it) }}</span> |
| | | <span v-if="!it.ready" class="cov-bad" style="margin-left:10px">{{ it.note }}</span> |
| | | <span v-if="it.ready && it.audited === false" class="cov-bad" style="margin-left:10px">未审核</span> |
| | | </div> |
| | | <table class="preview-summary wb-table" v-if="it.key === 'summaryWorkbook' && it.summary && it.summary.length"> |
| | | <thead> |
| | | <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)"> |
| | | <tr v-for="(row, ri) in sec.rows" :key="si + '-' + ri"> |
| | | <td v-if="ri === 0" :rowspan="sec.rows.length" class="wb-sec">{{ sec.name }}</td> |
| | | <td class="wb-label">{{ row.label }}</td> |
| | | <td class="wb-value">{{ row.value === null ? '—' : fmtVal(row.value) }}</td> |
| | | <td class="wb-yoy" :class="{ 'yoy-empty': row.yoy === null }">{{ row.yoy === null ? '—' : row.yoy }}</td> |
| | | </tr> |
| | | </template> |
| | | </tbody> |
| | | </table> |
| | | <div v-if="it.key === 'summaryWorkbook' && wbMissing(it.summary)" class="wb-note"> |
| | | 部分指标缺少上年同期({{ wbPrevYear() }})基期数据,同比暂未计算 |
| | | </div> |
| | | <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> |
| | | <td><span v-if="s.value === null || s.value === ''" class="cell-empty">(空)</span>{{ fmtVal(s.value) }}</td> |
| | | <td style="white-space:nowrap;color:#909399">{{ s.unit || '' }}</td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | <div class="preview-problems" v-if="it.problems && it.problems.length"> |
| | | <div v-for="(p, i) in it.problems" :key="i" class="prob-item">· {{ p }}</div> |
| | | </div> |
| | | <div class="preview-ok" v-else-if="it.ready">数据完整,可正常生成</div> |
| | | </div> |
| | | </div> |
| | | <span slot="footer"> |
| | | <el-button size="small" @click="previewVisible = false">关闭</el-button> |
| | | </span> |
| | | </el-dialog> |
| | | |
| | | <!-- 备注编辑弹窗 --> |
| | | <el-dialog title="编辑备注" :visible.sync="noteDialogVisible" width="480px"> |
| | | <el-input v-model="noteDraft" type="textarea" :rows="4" placeholder="填写该报表的备注说明(保存后本地记忆)"></el-input> |
| | | <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.staleCache">上次生成的结果已因数据或母版变化失效,本次按最新数据重算。</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> |
| | | </el-container> |
| | | </el-container> |
| | | </template> |
| | | |
| | | <script> |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import api from '@/api' |
| | | |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'ReportExport', |
| | | data() { |
| | | return { period: '', loading: '' } |
| | | return { |
| | | loading: '', |
| | | activeKey: '', |
| | | updatedAt: '', |
| | | // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy) |
| | | cycle: 'month', |
| | | period: '', |
| | | // 预览弹窗 |
| | | previewVisible: false, |
| | | previewItems: [], |
| | | previewModule: '', |
| | | previewPeriod: '', |
| | | // 备注编辑弹窗 |
| | | noteDialogVisible: false, |
| | | noteEditingKey: '', |
| | | noteDraft: '', |
| | | // 汇总大表异步生成任务 |
| | | taskVisible: false, |
| | | taskInfo: {}, |
| | | taskTimer: null, |
| | | // 进度弹窗:完成后会自动下载一次,这里记录「是否已下载」与文件信息(已下载就不再显示下载按钮) |
| | | taskDownloaded: false, |
| | | taskFileName: '', |
| | | taskFileSize: 0, |
| | | taskKey: 'summaryWorkbook', |
| | | modules: [ |
| | | { |
| | | key: 'summary', title: '汇总大表', items: [ |
| | | { key: 'summaryWorkbook', label: '道路运输量汇总表(整本)', fileName: '生成_道路运输量汇总表.xlsx', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, |
| | | { |
| | | key: 'freight', title: '货运报表', hidden: true, items: [ |
| | | { key: 'cityDetail', label: '货运量分市州明细', fileName: '生成_货运量分市州明细.xlsx', status: null, statusNote: '' }, |
| | | { key: 'freightRank', label: '货运量排名', fileName: '生成_货运量排名.xlsx', status: null, statusNote: '' }, |
| | | { key: 'turnoverRank', label: '周转量排名', fileName: '生成_周转量排名.xlsx', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, |
| | | { |
| | | key: 'passenger', title: '旅客报表', hidden: true, items: [ |
| | | { key: 'passengerCityDetail', label: '公路旅客分市州', fileName: '生成_公路旅客分市州.xlsx', status: null, statusNote: '' }, |
| | | { key: 'passengerMidDetail', label: '中口径明细', fileName: '生成_公路旅客中口径明细.xlsx', status: null, statusNote: '' }, |
| | | { key: 'passengerMidRank', label: '中口径排名', fileName: '生成_公路旅客中口径排名.xlsx', status: null, statusNote: '' }, |
| | | { key: 'passengerMidAnalysis', label: '中口径分析', fileName: '生成_公路旅客中口径分析.xlsx', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, { |
| | | key: 'cityPassenger', title: '城市客运报表', hidden: true, items: [ |
| | | { key: 'cityBusDetail', label: '城市公交客运量分市州明细', fileName: '生成_城市公交客运量分市州明细.xlsx', status: null, statusNote: '' }, |
| | | { key: 'cityTaxiDetail', label: '巡游出租客运量分市州明细', fileName: '生成_巡游出租客运量分市州明细.xlsx', status: null, statusNote: '' }, |
| | | { key: 'cityRailFerryDetail', label: '轨道轮渡客运量分市州明细', fileName: '生成_轨道轮渡客运量分市州明细.xlsx', status: null, statusNote: '' }, |
| | | { key: 'cityPassengerCitySum', label: '城市客运客运量分市州累计', fileName: '生成_城市客运客运量分市州累计.xlsx', status: null, statusNote: '' }, |
| | | { key: 'cityPassengerDetailSum', label: '城市客运各市州明细表', fileName: '生成_城市客运各市州明细表.xlsx', status: null, statusNote: '' }, |
| | | { key: 'cityPassengerSummary', label: '城市客运汇总', fileName: '生成_城市客运汇总.xlsx', status: null, statusNote: '' }, |
| | | { key: 'wycSplit', label: '网约车客运量拆分表', fileName: '生成_网约车客运量拆分表.xlsx', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, |
| | | { |
| | | key: 'invest', 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: 'investPassengerDetail', label: '客运站投资明细表', fileName: '生成_X月客运站投资明细表.xlsx', status: null, statusNote: '' }, |
| | | { key: 'investLogisticsDetail', label: '物流站场投资明细', fileName: '生成_X月物流站场投资明细.xls', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | }, |
| | | { |
| | | key: 'energy', title: '能耗报表', items: [ |
| | | { key: 'energySummary', label: '能运汇总表', fileName: '生成_能运汇总表.xlsx', status: null, statusNote: '' } |
| | | ], checked: [] |
| | | } |
| | | ] |
| | | } |
| | | }, |
| | | computed: { |
| | | username() { return sessionStorage.getItem('username') || '' }, |
| | | realName() { return sessionStorage.getItem('realName') || '' }, |
| | | myModules() { |
| | | const s = sessionStorage.getItem('modules') |
| | | if (!s) return [] |
| | | return s.split(',').filter(Boolean) |
| | | }, |
| | | visibleModules() { |
| | | return this.modules.filter(m => !m.hidden) |
| | | }, |
| | | curModule() { |
| | | return this.modules.find(m => m.key === this.activeKey) || this.modules[0] |
| | | }, |
| | | allChecked() { |
| | | const m = this.curModule |
| | | return m.items.length > 0 && m.checked.length === m.items.length |
| | | }, |
| | | // 周期选项:能耗为季报(季度末月),其余为月报 / 年报 |
| | | cycleOptions() { |
| | | if (this.activeKey === 'energy') return [{ label: '季报', value: 'quarter' }] |
| | | return [{ label: '月报', value: 'month' }, { label: '年报', value: 'year' }] |
| | | }, |
| | | periodPlaceholder() { |
| | | return this.cycle === 'year' ? '选择年份' : '选择月份' |
| | | }, |
| | | // 报表期下拉分组:年报按年份;月报/季报按年分组列 1-12 月(能耗只列季度末月) |
| | | periodGroups() { |
| | | const year = new Date().getFullYear() |
| | | const years = [year, year - 1, year + 1] |
| | | if (this.cycle === 'year') { |
| | | return [{ label: '选择年份', options: years.map(y => ({ label: y + '年', value: String(y) })) }] |
| | | } |
| | | const months = this.activeKey === 'energy' ? [3, 6, 9, 12] : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] |
| | | return years.map(y => ({ |
| | | label: y + '年' + (this.activeKey === 'energy' ? '(季度末月)' : ''), |
| | | 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.ensureCycle() |
| | | this.refreshAll() |
| | | } |
| | | }, |
| | | created() { |
| | | const mine = this.myModules.filter(k => this.visibleModules.some(m => m.key === k)) |
| | | this.activeKey = (mine.length ? mine : this.visibleModules.map(m => m.key))[0] |
| | | this.ensureCycle() |
| | | this.refreshAll() |
| | | }, |
| | | beforeDestroy() { |
| | | this.stopTaskPolling() |
| | | }, |
| | | methods: { |
| | | exportReport(type) { |
| | | if (!this.period) { this.$message.warning('请选择报表期'); return } |
| | | this.loading = type |
| | | const token = localStorage.getItem('token') || '' |
| | | api.get('/report/export/' + type, { params: { period: this.period }, responseType: 'blob' }) |
| | | .then(res => { |
| | | const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) |
| | | const url = window.URL.createObjectURL(blob) |
| | | const a = document.createElement('a') |
| | | a.href = url |
| | | a.download = this.fileName(type) |
| | | document.body.appendChild(a) |
| | | a.click() |
| | | window.URL.revokeObjectURL(url) |
| | | this.$message.success('报表生成成功') |
| | | }) |
| | | .catch(() => { this.$message.error('报表生成失败,请确认已导入对应数据') }) |
| | | .finally(() => { this.loading = '' }) |
| | | moduleTitle(key) { |
| | | const m = this.modules.find(x => x.key === key) |
| | | return m ? m.title : key |
| | | }, |
| | | fileName(type) { |
| | | const map = { |
| | | cityDetail: '生成_货运量分市州明细.xlsx', |
| | | freightRank: '生成_货运量排名.xlsx', |
| | | turnoverRank: '生成_周转量排名.xlsx' |
| | | logout() { |
| | | sessionStorage.removeItem('token'); sessionStorage.removeItem('username'); sessionStorage.removeItem('realName'); sessionStorage.removeItem('modules') |
| | | this.$router.push('/login') |
| | | }, |
| | | // ==================== 查询条件与状态刷新 ==================== |
| | | buildParams() { |
| | | const p = { period: this.period } |
| | | p.mode = this.cycle === 'year' ? 'year' : 'month' |
| | | return p |
| | | }, |
| | | // 周期与报表期匹配:切换周期后清掉不匹配的旧值 |
| | | normalizePeriod() { |
| | | const isYear = /^\d{4}$/.test(this.period || '') |
| | | const isMonth = /^\d{4}-\d{2}$/.test(this.period || '') |
| | | if (this.cycle === 'year' && isMonth) this.period = '' |
| | | if (this.cycle !== 'year' && isYear) this.period = '' |
| | | }, |
| | | ensureCycle() { |
| | | const allowed = this.cycleOptions.map(o => o.value) |
| | | if (!allowed.includes(this.cycle)) this.cycle = allowed[0] |
| | | this.normalizePeriod() |
| | | }, |
| | | onCycleChange() { |
| | | this.normalizePeriod() |
| | | this.refreshAll() |
| | | }, |
| | | onPeriodChange() { |
| | | this.refreshAll() |
| | | }, |
| | | doQuery() { |
| | | if (!this.period) { this.$message.warning('请先选择周期和月份'); return } |
| | | this.refreshAll() |
| | | }, |
| | | refreshAll() { |
| | | this.updatedAt = new Date().toLocaleString('zh-CN', { hour12: false }) |
| | | this.refreshStatus() |
| | | }, |
| | | refreshStatus() { |
| | | const m = this.curModule |
| | | if (!m || !this.period) return |
| | | const params = Object.assign(this.buildParams(), { types: m.items.map(i => i.key).join(',') }) |
| | | api.get('/report/ready', { params, timeout: 60000 }) |
| | | .then(res => { |
| | | const list = (res && res.data && res.data.items) || [] |
| | | list.forEach(r => { |
| | | const it = m.items.find(x => x.key === r.key) |
| | | if (!it) return |
| | | if (!r.ready) { it.status = 'missing'; it.statusNote = r.note || '缺数据' } |
| | | else if (r.audited === false) { it.status = 'unaudited'; it.statusNote = '尚未审核通过' } |
| | | else { it.status = 'ready'; it.statusNote = '' } |
| | | }) |
| | | }) |
| | | .catch(() => {}) |
| | | }, |
| | | noteOf(key) { |
| | | return localStorage.getItem('report_note_' + key) || '' |
| | | }, |
| | | openNoteDialog(row) { |
| | | this.noteEditingKey = row.key |
| | | this.noteDraft = this.noteOf(row.key) |
| | | this.noteDialogVisible = true |
| | | }, |
| | | saveNote() { |
| | | localStorage.setItem('report_note_' + this.noteEditingKey, this.noteDraft.trim()) |
| | | this.noteDialogVisible = false |
| | | this.$message.success('备注已保存') |
| | | }, |
| | | // ==================== 勾选 / 预览 / 导出 ==================== |
| | | toggleChecked(row) { |
| | | const checked = this.curModule.checked |
| | | const i = checked.indexOf(row.key) |
| | | if (i >= 0) checked.splice(i, 1) |
| | | else checked.push(row.key) |
| | | }, |
| | | toggleAll(v) { |
| | | this.curModule.checked = v ? this.curModule.items.map(i => i.key) : [] |
| | | }, |
| | | exportOne(item) { this.exportChecked([item]) }, |
| | | previewOne(item) { this.previewChecked([item]) }, |
| | | previewChecked(itemList) { |
| | | const m = this.curModule |
| | | if (!this.period) { this.$message.warning('请先选择周期和月份'); return } |
| | | const items = itemList || m.items.filter(i => m.checked.includes(i.key)) |
| | | if (!items.length) { this.$message.warning('请选择要预览的报表'); return } |
| | | this.loading = 'preview-' + m.key |
| | | const params = Object.assign(this.buildParams(), { types: items.map(i => i.key).join(',') }) |
| | | api.get('/report/preview', { params, timeout: 60000 }) |
| | | .then(res => { |
| | | const list = (res && res.data && res.data.items) || [] |
| | | this.previewItems = list.map(it => { |
| | | const item = items.find(x => x.key === it.key) |
| | | return Object.assign({}, it, { label: item ? item.label : it.key }) |
| | | }) |
| | | this.previewModule = m.title |
| | | this.previewPeriod = this.period |
| | | this.previewVisible = true |
| | | this.loading = '' |
| | | }) |
| | | .catch(err => this.failWith(err, '预览')) |
| | | }, |
| | | fmtVal(v) { |
| | | if (v === null || v === undefined || v === '') return '—' |
| | | if (typeof v === 'number') { |
| | | if (Number.isInteger(v)) return String(v) |
| | | return v.toFixed(4).replace(/\.?0+$/, '') |
| | | } |
| | | return map[type] || 'report.xlsx' |
| | | return String(v) |
| | | }, |
| | | wbRows(summary) { |
| | | const get = (key) => { |
| | | const hit = (summary || []).find(x => x.label === key) |
| | | return hit && hit.value !== null && hit.value !== undefined && hit.value !== '' ? hit.value : null |
| | | } |
| | | const pct = (key) => { |
| | | const v = get(key) |
| | | return v === null ? null : this.fmtVal(v) + '%' |
| | | } |
| | | const mk = (defs) => defs.map(d => ({ |
| | | label: d[0], |
| | | value: get(d[1]), |
| | | yoy: d[2] ? pct(d[2]) : null |
| | | })) |
| | | return [ |
| | | { |
| | | name: '道路货运', |
| | | rows: mk([ |
| | | ['当月货运量(万吨)', '全省货运量·当月', '全省货运量·当月同比'], |
| | | ['当月货物周转量(万吨公里)', '全省货物周转量·当月', '全省货物周转量·当月同比'], |
| | | ['累计货运量(万吨)', '全省货运量·累计', '全省货运量·累计同比'], |
| | | ['累计货物周转量(万吨公里)', '全省货物周转量·累计', '全省货物周转量·累计同比'] |
| | | ]) |
| | | }, |
| | | { |
| | | name: '城市客运', |
| | | rows: mk([ |
| | | ['当月城市客运量', '城市客运量·当月', '城市客运量·当月同比'], |
| | | ['当月城市客运周转量', '城市客运周转量·当月', '城市客运周转量·当月同比'], |
| | | ['累计城市客运量', '城市客运量·累计', '城市客运量·累计同比'], |
| | | ['累计城市客运周转量', '城市客运周转量·累计', '城市客运周转量·累计同比'] |
| | | ]) |
| | | }, |
| | | { |
| | | name: '中口径', |
| | | rows: mk([ |
| | | ['当月中口径客运量', '中口径客运量·当月', '中口径客运量·当月同比'], |
| | | ['当月中口径周转量', '中口径周转量·当月', '中口径周转量·当月同比'], |
| | | ['累计中口径客运量', '中口径客运量·累计', '中口径客运量·累计同比'], |
| | | ['累计中口径周转量', '中口径周转量·累计', '中口径周转量·累计同比'] |
| | | ]) |
| | | } |
| | | ] |
| | | }, |
| | | wbMissing(summary) { |
| | | let total = 0 |
| | | let have = 0 |
| | | ;(summary || []).forEach(x => { |
| | | if (String(x.label).indexOf('同比') >= 0) return |
| | | total++ |
| | | if (x.value !== null && x.value !== undefined && x.value !== '') have++ |
| | | }) |
| | | return total > 0 && have < total |
| | | }, |
| | | wbPrevYear() { |
| | | const m = /^(\d{4})-(\d{2})$/.exec(this.previewPeriod || '') |
| | | if (!m) return '上年同期' |
| | | return (Number(m[1]) - 1) + '-' + m[2] |
| | | }, |
| | | coverageText(it) { |
| | | const cov = it.monthCoverage || [] |
| | | if (!cov.length) return '无数据' |
| | | const continuous = cov.length === cov[cov.length - 1] |
| | | 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 items = itemList || m.items.filter(i => m.checked.includes(i.key)) |
| | | if (!items.length) { this.$message.warning('请选择要导出的报表'); return } |
| | | this.loading = 'gen-' + m.key |
| | | 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, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' }) |
| | | .catch(err => this.failWith(err)) |
| | | return |
| | | } |
| | | const zipParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') }) |
| | | api.get('/report/export/selected', { params: zipParams, responseType: 'blob', timeout: 120000 }) |
| | | .then(res => { this.downloadBlob(res, '生成_所选报表_' + this.period + '.zip'); this.$message.success('已导出 ' + items.length + ' 张报表(zip)'); this.loading = '' }) |
| | | .catch(err => this.failWith(err)) |
| | | } |
| | | const readyParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') }) |
| | | api.get('/report/ready', { params: readyParams, timeout: 60000 }) |
| | | .then(rd => { |
| | | const list = (rd && rd.data && rd.data.items) || [] |
| | | const missing = list.filter(i => !i.ready) |
| | | const unaudited = list.filter(i => i.ready && i.audited === false) |
| | | const lines = [] |
| | | missing.forEach(i => { |
| | | const it = items.find(x => x.key === i.key) |
| | | lines.push('【缺数据】' + (it ? it.label : i.key) + '(' + i.note + ')') |
| | | }) |
| | | unaudited.forEach(i => { |
| | | const it = items.find(x => x.key === i.key) |
| | | lines.push('【未审核】' + (it ? it.label : i.key)) |
| | | }) |
| | | if (!lines.length) { doExport(); return } |
| | | this.$confirm('以下报表请注意:\n' + lines.join('\n') + '\n\n仍要导出吗?', '导出前提示', { |
| | | confirmButtonText: '仍要导出', cancelButtonText: '取消', type: 'warning' |
| | | }).then(() => doExport()).catch(() => { this.loading = '' }) |
| | | }) |
| | | .catch(() => doExport()) |
| | | }, |
| | | // 预览/导出失败统一提示:区分“连不上服务器”与“后端返回的错误原因”。 |
| | | // 导出接口用 responseType: 'blob',报错时 data 也是 Blob,必须先读文本再解析, |
| | | // 否则拿不到后端真实原因,只会显示笼统的“请确认已导入对应数据”。 |
| | | failWith(err, action) { |
| | | if (err && err._toast) { this.loading = ''; return } |
| | | 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) |
| | | const isXls = /\.xls$/.test(fileName) |
| | | const blob = new Blob([res], { type: isZip ? 'application/zip' : (isXls ? 'application/vnd.ms-excel' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') }) |
| | | const url = window.URL.createObjectURL(blob) |
| | | const a = document.createElement('a') |
| | | a.href = url |
| | | a.download = fileName |
| | | document.body.appendChild(a) |
| | | a.click() |
| | | window.URL.revokeObjectURL(url) |
| | | } |
| | | } |
| | | } |
| | | </script> |
| | | <style scoped> |
| | | </script><style scoped> |
| | | .el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; } |
| | | .page-scroll { max-width: 1200px; } |
| | | .topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } |
| | | .topbar h3 { margin: 0; } |
| | | .topbar-right { display: flex; align-items: center; gap: 12px; font-size: 13px; color: #606266; } |
| | | .update-time { font-size: 12px; color: #909399; } |
| | | .module-tabs { margin-bottom: 6px; } |
| | | .query-panel { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } |
| | | .data-card { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; } |
| | | .card-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; } |
| | | .muted { font-size: 12px; color: #909399; font-weight: 400; } |
| | | .module-panel { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 14px 16px; } |
| | | .ctrl-label { font-size: 13px; color: #606266; } |
| | | .panel-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } |
| | | .panel-title { font-size: 16px; font-weight: 600; color: #303133; display: flex; align-items: center; gap: 6px; } |
| | | .panel-controls { display: flex; align-items: center; gap: 10px; } |
| | | .report-table { width: 100%; } |
| | | .checked-count { font-size: 12px; color: #909399; } |
| | | .panel-footer { margin-top: 10px; font-size: 12px; color: #909399; text-align: right; } |
| | | .status-note { font-size: 11px; color: #F56C6C; margin-top: 2px; line-height: 1.4; } |
| | | .note-text { font-size: 12px; color: #606266; margin-right: 4px; } |
| | | .spacer { flex: 1; } |
| | | .preview-body { max-height: 68vh; overflow-y: auto; } |
| | | .preview-item { border: 1px solid #ebeef5; border-radius: 6px; padding: 12px 14px; margin-bottom: 12px; } |
| | | .preview-head { display: flex; align-items: center; margin-bottom: 8px; } |
| | | .preview-name { font-weight: 600; font-size: 15px; margin-right: 10px; } |
| | | .preview-rows { margin-left: auto; color: #909399; font-size: 12px; } |
| | | .preview-coverage { font-size: 13px; margin-bottom: 8px; } |
| | | .cov-ok { color: #67C23A; } |
| | | .cov-bad { color: #F56C6C; } |
| | | .cov-label { color: #909399; } |
| | | .preview-summary { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; } |
| | | .preview-summary th, .preview-summary td { border: 1px solid #ebeef5; padding: 4px 8px; text-align: left; } |
| | | .preview-summary th { background: #f5f7fa; } |
| | | .preview-summary .cell-empty { color: #F56C6C; } |
| | | .preview-problems { font-size: 13px; color: #F56C6C; } |
| | | .preview-problems .prob-item { margin: 2px 0; } |
| | | .preview-ok { font-size: 13px; color: #67C23A; } |
| | | .tip-text { font-size: 13px; line-height: 1.7; } |
| | | .tip-text p { margin: 2px 0; } |
| | | @media (max-width: 900px) { |
| | | .panel-head { flex-direction: column; align-items: flex-start; } |
| | | } |
| | | .wb-table th { background:#f5f7fa; } |
| | | .wb-sec { background:#F7F9FC; font-weight:600; text-align:center; color:#303133; } |
| | | .wb-label { color:#303133; } |
| | | .wb-value { text-align:right; font-variant-numeric: tabular-nums; } |
| | | .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> |