1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 报表生成"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router :default-active="activeMenu">
          <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-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
          <!-- 第一行:标题 + 更新时间 -->
          <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>
 
          <!-- 第二行:报表类型 Tab(货运 / 旅客 / 投资 / 能耗 / 城市客运) -->
          <el-tabs v-model="activeKey" class="module-tabs">
            <el-tab-pane v-for="m in modules" :key="m.key" :name="m.key">
              <span slot="label">{{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag></span>
            </el-tab-pane>
          </el-tabs>
 
          <!-- 第三行:查询条件区(周期 + 月份 + 查询) -->
          <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">
          <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>
              </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" v-if="it.summary && it.summary.length">
                <tbody>
                  <tr v-for="(s, i) in it.summary" :key="i">
                    <td>{{ s.name }}</td>
                    <td><span v-if="s.value === null || s.value === ''" class="cell-empty">(空)</span>{{ fmtVal(s.value) }}</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>
      </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 {
      loading: '',
      activeMenu: '/report',
      activeKey: '',
      updatedAt: '',
      // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy)
      cycle: 'month',
      period: '',
      // 预览弹窗
      previewVisible: false,
      previewItems: [],
      previewModule: '',
      previewPeriod: '',
      // 备注编辑弹窗
      noteDialogVisible: false,
      noteEditingKey: '',
      noteDraft: '',
      modules: [
        {
          key: 'freight', title: '货运报表', 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: '旅客报表', 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: '城市客运报表', tag: '公交/出租/轨道轮渡/汇总', tagType: 'success', 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: '' }
          ], 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: '' }
          ], checked: []
        },
        {
          key: 'energy', title: '能耗报表', items: [
            { key: 'energySummary', label: '能运汇总表', fileName: '生成_能运汇总表.xlsx', status: null, statusNote: '' }
          ], checked: []
        }
      ]
    }
  },
  computed: {
    username() { return localStorage.getItem('username') || '' },
    realName() { return localStorage.getItem('realName') || '' },
    myModules() {
      const s = localStorage.getItem('modules')
      if (!s) return []
      return s.split(',').filter(Boolean)
    },
    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 - 1, year, 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') }))
      }))
    },
  },
  watch: {
    activeKey() {
      this.ensureCycle()
      this.refreshAll()
    }
  },
  created() {
    const mine = this.myModules.filter(k => this.modules.some(m => m.key === k))
    this.activeKey = (mine.length ? mine : this.modules.map(m => m.key))[0]
    this.ensureCycle()
    this.refreshAll()
  },  methods: {
    moduleTitle(key) {
      const m = this.modules.find(x => x.key === key)
      return m ? m.title : key
    },
    logout() {
      localStorage.removeItem('token'); localStorage.removeItem('username'); localStorage.removeItem('realName'); localStorage.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 String(v)
    },
    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('、') + ' 有数据'
    },
    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) {
          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 = '' })
            .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())
    },
    failWith(err) {
      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 = ''
    },
    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>
.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; }
}
</style>