fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed

1) 跨账套混算修复(最高优先):/stats 的 total_logs/open_logs/severity_counts/type_counts
   原先直查 kpi_data_quality_log 无任何账套过滤(全库数),而同页 /logs 已按账套收口,
   导致「卡片数字与下方列表对不上」。现四项与 /logs 共用 _scoped_quality_log_query
   (kpi_id join kpi_definitions,内连接,孤儿日志不计)。零 DDL:不给该表加 entity_id 列。
2) /logs 服务端分页(page/page_size,默认 50、上限 200)+ total 改真实 COUNT(*)
   (原为截断后行数 len(),且 limit=100 静默截断)。
3) 新增只读端点 GET /missing-data-kpis:「无实际值 KPI」清单(服务端分页 + 真实 total,
   口径与 /stats.data_missing 逐字一致),供「无实际值 KPI 占比」卡片下钻。
4) 环图黑块修复:ECharts 走 canvas,不解析 CSS 变量,原 itemStyle.color 传 CSS 变量会回退黑色。
   改合法 HEX 实值(与主题色同值 #F56C6C / #E6A23C)。全仓同类写法扫描:33 处 CSS 变量命中,
   仅此 2 处在 ECharts 上下文(已修);ReportCenter.vue:848-849(经 bscScoreColor() 流向 :style)、
   KPIDetail.vue:244/267(el-timeline-item :color 落 inline style)均为 CSS 上下文,非缺陷,未改。
5) 阈值文案单一来源:新增 STALE_THRESHOLD_DAYS = 180,接口 threshold_days 与之同源,
   后端注释同步修正;阈值本身保持 180 不动(改阈值属口径变更,须另行拍板)。
6) 前端 DataQuality.vue:卡片标题加维度限定词(KPI元数据完整度评分 / 无实际值 KPI 占比 /
   超 N 天未更新预警由接口渲染)、顶部四项加层级标注(记录数 vs 实体数)、三张异常卡 + 无实际值
   KPI 可点击,用 MyDialog 打开明细(0 值置灰不可点,服务端分页 50/页,导出 CSV 全量)、
   质量异常日志列表改服务端分页(显示 total,与卡片同口径)。未新增 el-dialog、未改 api/index.ts。

未动:KPI 检查算法 / 检查类型集合 / 异常判定阈值;未回退「财务七规则」标签页既有下钻
(govDetail* 与 rule-detail 端点)。
This commit is contained in:
Hermes CI Fix
2026-09-24 00:40:11 +08:00
parent 467472311d
commit 72d4d97c83
2 changed files with 448 additions and 41 deletions
+351 -22
View File
@@ -15,47 +15,65 @@
</div>
</div>
<!-- 统计卡片 -->
<!-- 统计卡片(层级标注:异常类=记录数,KPI总数=实体数;异常类数字可点下钻) -->
<el-row :gutter="16" class="section-gap">
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#409eff;">{{ stats.total_kpis }}</div>
<div class="stat-label">KPI总数</div>
<div class="stat-label">KPI总数(实体数)</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:var(--bsc-learning);">{{ stats.open_logs }}</div>
<div class="stat-label">未解决异常</div>
<div
class="stat-value"
:class="{ 'stat-value-link': cardCount('open') > 0, 'is-zero': !cardCount('open') }"
:style="{ color: cardCount('open') > 0 ? 'var(--bsc-learning)' : '#c0c4cc' }"
:title="cardCount('open') > 0 ? '点击查看全部未解决异常明细' : '无未解决异常'"
@click="openKpiDetail('open')"
>{{ stats.open_logs ?? 0 }}</div>
<div class="stat-label">未解决异常(记录数)</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#f56c6c;">{{ stats.severity_counts?.critical ?? 0 }}</div>
<div class="stat-label">严重异常</div>
<div
class="stat-value"
:class="{ 'stat-value-link': cardCount('critical') > 0, 'is-zero': !cardCount('critical') }"
:style="{ color: cardCount('critical') > 0 ? '#f56c6c' : '#c0c4cc' }"
:title="cardCount('critical') > 0 ? '点击查看全部严重异常明细' : '无严重异常'"
@click="openKpiDetail('critical')"
>{{ stats.severity_counts?.critical ?? 0 }}</div>
<div class="stat-label">严重异常(记录数)</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#67c23a;">{{ stats.type_counts?.missing_data ?? 0 }}</div>
<div class="stat-label">数据缺失</div>
<div
class="stat-value"
:class="{ 'stat-value-link': cardCount('missing_data') > 0, 'is-zero': !cardCount('missing_data') }"
:style="{ color: cardCount('missing_data') > 0 ? '#67c23a' : '#c0c4cc' }"
:title="cardCount('missing_data') > 0 ? '点击查看全部数据缺失明细' : '无数据缺失'"
@click="openKpiDetail('missing_data')"
>{{ stats.type_counts?.missing_data ?? 0 }}</div>
<div class="stat-label">数据缺失(记录数)</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 数据审计看板 -->
<!-- 数据审计看板(标题含维度限定词,防「同一页两把尺子」误读) -->
<el-row :gutter="16" class="section-gap">
<el-col :span="8">
<el-card shadow="never">
<template #header>📊 KPI完整度评分</template>
<template #header>📊 KPI元数据完整度评分</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: completenessColor }">
{{ stats.completeness?.score ?? '-' }}%
@@ -66,6 +84,7 @@
<div style="font-size:12px;color:#f56c6c;margin-top:8px;">
缺失元数据: {{ stats.completeness?.missing_metadata ?? 0 }} 个KPI
</div>
<div class="dq-card-caliber">口径:元数据五项(公式/数据源/负责人/单位/目标值)齐全的 KPI 占比(实体数)</div>
<el-progress
:percentage="stats.completeness?.score ?? 0"
:stroke-width="12"
@@ -77,17 +96,24 @@
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>📉 缺失率统计</template>
<template #header>📉 无实际值 KPI 占比</template>
<div style="text-align:center;padding:12px 0;">
<div style="font-size:36px;font-weight:700;color:#e6a23c;">
{{ stats.data_missing?.rate ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
无数据值的KPI: {{ stats.data_missing?.count ?? 0 }}
无数据值的KPI:
<span
class="dq-count-link"
:class="{ 'is-zero': !cardCount('no_value_kpi') }"
:title="cardCount('no_value_kpi') > 0 ? '点击查看无实际值的 KPI 清单' : '无此类 KPI'"
@click="openKpiDetail('no_value_kpi')"
>{{ stats.data_missing?.count ?? 0 }}</span>
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">
总计 {{ stats.data_missing?.total ?? 0 }} 个KPI
</div>
<div class="dq-card-caliber">口径:active KPI 中无任何实际值(kpi_values.actual_value 非空行为 0)的占比(实体数)</div>
<el-progress
:percentage="stats.data_missing?.rate ?? 0"
:stroke-width="12"
@@ -99,14 +125,16 @@
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>⚠️ 超30天未更新预警</template>
<!-- 天数唯一来源 = 接口 threshold_days(前端不写死天数) -->
<template #header>⚠️ 超 {{ staleThresholdDays }} 天未更新预警</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: staleDataColor }">
{{ stats.stale_data?.count ?? 0 }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新
个KPI超过 {{ staleThresholdDays }} 天未更新
</div>
<div class="dq-card-caliber">口径:最近一次有效值入库时间早于阈值天数的 KPI(实体数)</div>
<el-tag v-if="(stats.stale_data?.count ?? 0) > 0" type="danger" size="small" style="margin-top:8px;">
建议立即检查数据源
</el-tag>
@@ -124,6 +152,10 @@
<el-card shadow="never">
<template #header>异常类型分布</template>
<v-chart :option="typeChartOption" autoresize style="height:200px;" />
<div class="dq-card-caliber dq-chart-hint">
口径:本账套 status=open 记录中 4 类 KPI 值检查(数据缺失/环比骤变/连续持平/值异常)的条数;
合计可能小于「未解决异常」总数(另有其它检查类型,未纳入本图)。
</div>
</el-card>
</el-col>
</el-row>
@@ -134,12 +166,12 @@
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>质量异常日志</span>
<div>
<el-select v-model="filterSeverity" placeholder="严重程度" clearable size="small" style="width:140px;margin-right:8px;" @change="loadLogs">
<el-select v-model="filterSeverity" placeholder="严重程度" clearable size="small" style="width:140px;margin-right:8px;" @change="reloadLogs">
<el-option label="严重" value="critical" />
<el-option label="警告" value="warning" />
<el-option label="一般" value="info" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width:120px;" @change="loadLogs">
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width:120px;" @change="reloadLogs">
<el-option label="未处理" value="open" />
<el-option label="已处理" value="resolved" />
<el-option label="已忽略" value="ignored" />
@@ -188,6 +220,21 @@
</el-table-column>
</el-table>
<el-empty v-if="!loading && logs.length === 0" description="暂无质量异常日志" />
<!-- 服务端分页(2026-09-24):下方列表 total 与上方卡片数字共用同一账套口径 -->
<div class="dq-detail-foot">
<span class="dq-detail-hint">共 {{ logTotal }} 条(本账套,服务端分页每页 {{ logPageSize }} 条)</span>
<div style="flex:1"></div>
<el-pagination
v-model:current-page="logPage"
:page-size="logPageSize"
:total="logTotal"
layout="prev, pager, next"
background
small
:disabled="loading"
@current-change="loadLogs"
/>
</div>
</el-card>
</el-tab-pane>
@@ -357,6 +404,85 @@
<el-button size="small" @click="govDetailVisible = false">关闭</el-button>
</template>
</MyDialog>
<!-- KPI质量监控 异常明细下钻(2026-09-24 P1):卡片数字可点看全量
项目铁律:一律用 MyDialog(Element 原生弹窗有已知 bug,禁用) -->
<MyDialog v-model="kpiDetailVisible" :title="kpiDetailTitle" :width="980">
<div class="dq-detail">
<div class="dq-detail-bar">
<el-tag size="small" :type="kpiDetailTagType">{{ kpiDetailMode === 'logs' ? '记录数(条)' : '实体数(个)' }}</el-tag>
<span class="dq-detail-name">{{ kpiDetailLabel }}</span>
<span class="dq-detail-total">真实总数 <b>{{ kpiDetailTotal }}</b> 条</span>
<div style="flex:1"></div>
<el-button size="small" :loading="kpiExporting" :disabled="kpiDetailLoading" @click="exportKpiDetailCsv">
导出 CSV(全量 {{ kpiDetailTotal }} 条)
</el-button>
</div>
<div class="dq-detail-desc">口径说明:{{ kpiDetailDesc }}</div>
<el-table
v-if="kpiDetailItems.length"
:data="kpiDetailItems"
size="small"
border
max-height="420"
v-loading="kpiDetailLoading"
>
<!-- 日志类明细:字段与渲染沿用既有「质量异常日志」列表 -->
<el-table-column v-if="kpiDetailMode === 'logs'" prop="kpi_name" label="KPI" min-width="150" />
<el-table-column v-if="kpiDetailMode === 'logs'" prop="kpi_code" label="编码" width="100" />
<el-table-column v-if="kpiDetailMode === 'logs'" prop="check_type" label="异常类型" width="120">
<template #default="{ row }">
<el-tag :type="checkTypeTag(row.check_type)" size="small">{{ checkTypeText(row.check_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="kpiDetailMode === 'logs'" prop="severity" label="严重度" width="80">
<template #default="{ row }">
<el-tag :type="row.severity === 'critical' ? 'danger' : row.severity === 'warning' ? 'warning' : 'info'" size="small">{{ row.severity }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="kpiDetailMode === 'logs'" prop="detail" label="详情" min-width="250">
<template #default="{ row }">
<div>{{ detailText(row) }}</div>
</template>
</el-table-column>
<el-table-column v-if="kpiDetailMode === 'logs'" prop="suggestion" label="建议操作" min-width="200" />
<el-table-column v-if="kpiDetailMode === 'logs'" prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'open' ? 'danger' : row.status === 'resolved' ? 'success' : 'info'" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="kpiDetailMode === 'logs'" prop="created_at" label="发现时间" width="160" />
<!-- KPI 类明细(无实际值 KPI):KPI名/编码/负责人/数据源/单位 -->
<el-table-column v-if="kpiDetailMode === 'kpis'" prop="kpi_name" label="KPI" min-width="180" />
<el-table-column v-if="kpiDetailMode === 'kpis'" prop="kpi_code" label="编码" width="140" />
<el-table-column v-if="kpiDetailMode === 'kpis'" prop="data_owner" label="负责人" width="110" />
<el-table-column v-if="kpiDetailMode === 'kpis'" prop="data_source" label="数据源" width="140" />
<el-table-column v-if="kpiDetailMode === 'kpis'" prop="unit" label="单位" width="90" />
</el-table>
<el-empty v-else-if="!kpiDetailLoading" description="该口径当前无记录" :image-size="80" />
<div class="dq-detail-foot">
<span class="dq-detail-hint">明细为服务端分页(每页 {{ kpiDetailPageSize }} 条,共 {{ kpiDetailPageCount }} 页)</span>
<div style="flex:1"></div>
<el-pagination
v-model:current-page="kpiDetailPage"
:page-size="kpiDetailPageSize"
:total="kpiDetailTotal"
layout="prev, pager, next"
:disabled="kpiDetailLoading"
background
small
@current-change="fetchKpiDetail"
/>
</div>
</div>
<template #footer>
<el-button size="small" @click="kpiDetailVisible = false">关闭</el-button>
</template>
</MyDialog>
</div>
</template>
@@ -379,6 +505,14 @@ const checking = ref(false)
const filterSeverity = ref('')
const filterStatus = ref('')
// ── 质量异常日志:服务端分页(2026-09-24)──
const logTotal = ref(0)
const logPage = ref(1)
const logPageSize = 20
// 陈旧天数:唯一来源 = 接口 threshold_days(前端不写死天数)
const staleThresholdDays = computed(() => stats.value.stale_data?.threshold_days ?? '-')
// DAMA 数据治理规则检查状态(财务七规则)
const govScore = ref(0)
const govTotalDeduct = ref(0)
@@ -402,16 +536,25 @@ const govDetailPageSize = 50 // 与后端契约一致(默认 50、上限 200
const govDetailLoading = ref(false)
const govExporting = ref(false)
// 环图颜色一律用**合法 HEX 实值**(ECharts 走 canvas,不解析 CSS 变量,传 CSS 变量会
// 回退成黑色 —— 2026-09-24 修复)。取值与主题色一致:财务 #F56C6C / 学习成长 #E6A23C。
const CHART_COLORS = {
missing_data: '#f56c6c',
abnormal_change: '#e6a23c',
flat_data: '#409eff',
value_outlier: '#909399',
}
const typeChartOption = computed(() => ({
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ name: '数据缺失', value: stats.value.type_counts?.missing_data || 0, itemStyle: { color: 'var(--bsc-finance)' } },
{ name: '环比骤变', value: stats.value.type_counts?.abnormal_change || 0, itemStyle: { color: 'var(--bsc-learning)' } },
{ name: '连续持平', value: stats.value.type_counts?.flat_data || 0, itemStyle: { color: '#409eff' } },
{ name: '值异常', value: stats.value.type_counts?.value_outlier || 0, itemStyle: { color: '#909399' } },
{ name: '数据缺失', value: stats.value.type_counts?.missing_data || 0, itemStyle: { color: CHART_COLORS.missing_data } },
{ name: '环比骤变', value: stats.value.type_counts?.abnormal_change || 0, itemStyle: { color: CHART_COLORS.abnormal_change } },
{ name: '连续持平', value: stats.value.type_counts?.flat_data || 0, itemStyle: { color: CHART_COLORS.flat_data } },
{ name: '值异常', value: stats.value.type_counts?.value_outlier || 0, itemStyle: { color: CHART_COLORS.value_outlier } },
].filter(d => d.value > 0),
label: { show: true, formatter: '{b}: {c}' },
}],
@@ -578,6 +721,174 @@ async function exportGovDetailCsv() {
govExporting.value = false
}
// ── KPI质量监控 异常明细下钻(2026-09-24 P1:卡片数字可点 → MyDialog 全量明细)──
// 三处日志卡复用**已收口**的 /data-quality/logs(服务端分页,total 与卡片同一账套口径);
// 第四处「无实际值 KPI」走只读 /data-quality/missing-data-kpis(口径与 data_missing 一致)。
type KpiDrillKey = 'open' | 'critical' | 'missing_data' | 'no_value_kpi'
const KPI_DRILL: Record<KpiDrillKey, { label: string; title: string; desc: string; mode: 'logs' | 'kpis'; params?: any }> = {
open: {
label: '未解决异常', title: '异常明细 · 未解决异常', mode: 'logs',
desc: '本账套质量日志中 status=open 的全部记录(记录数,不是 KPI 个数)',
params: { status: 'open' },
},
critical: {
label: '严重异常', title: '异常明细 · 严重异常', mode: 'logs',
desc: '本账套质量日志中 status=open 且 severity=critical 的记录(记录数)',
params: { status: 'open', severity: 'critical' },
},
missing_data: {
label: '数据缺失', title: '异常明细 · 数据缺失', mode: 'logs',
desc: '本账套质量日志中 status=open 且 check_type=missing_data 的记录(记录数)',
params: { status: 'open', check_type: 'missing_data' },
},
no_value_kpi: {
label: '无实际值 KPI', title: '缺失明细 · 无实际值 KPI', mode: 'kpis',
desc: 'active KPI 且无任何实际值(kpi_values.actual_value 非空的行为 0)——与「无实际值 KPI 占比」卡片同一口径(实体数)',
},
}
const kpiDetailVisible = ref(false)
const kpiDetailKey = ref<KpiDrillKey>('open')
const kpiDetailItems = ref<any[]>([])
const kpiDetailTotal = ref(0)
const kpiDetailPage = ref(1)
const kpiDetailPageSize = 50 // 与后端契约一致(默认 50、上限 200)
const kpiDetailLoading = ref(false)
const kpiExporting = ref(false)
const kpiDetailMode = computed(() => KPI_DRILL[kpiDetailKey.value].mode)
const kpiDetailTitle = computed(() => KPI_DRILL[kpiDetailKey.value].title)
const kpiDetailLabel = computed(() => KPI_DRILL[kpiDetailKey.value].label)
const kpiDetailDesc = computed(() => KPI_DRILL[kpiDetailKey.value].desc)
const kpiDetailTagType = computed(() => (kpiDetailMode.value === 'logs' ? 'danger' : 'warning'))
const kpiDetailPageCount = computed(() => Math.max(1, Math.ceil(kpiDetailTotal.value / kpiDetailPageSize)))
/** 卡片当前数字(= /stats 同账套口径;用于「0 值置灰不可点」判定) */
function cardCount(key: KpiDrillKey): number {
if (key === 'open') return stats.value.open_logs ?? 0
if (key === 'critical') return stats.value.severity_counts?.critical ?? 0
if (key === 'missing_data') return stats.value.type_counts?.missing_data ?? 0
return stats.value.data_missing?.count ?? 0
}
/** 点卡片数字 → 打开明细;0 值置灰不可点(不开弹窗) */
function openKpiDetail(key: KpiDrillKey) {
if (!(cardCount(key) > 0)) return
kpiDetailKey.value = key
kpiDetailPage.value = 1
kpiDetailItems.value = []
kpiDetailTotal.value = cardCount(key) // 先落卡片数字(打开即与卡片一致),再用接口 total 校正
kpiDetailVisible.value = true
fetchKpiDetail()
}
/** 明细拉取(服务端分页;账套由会话 token 绑定,前端不传 entity_id) */
async function fetchKpiDetail(page?: number) {
if (typeof page === 'number') kpiDetailPage.value = page
kpiDetailLoading.value = true
try {
const pageParams = { page: kpiDetailPage.value, page_size: kpiDetailPageSize }
if (kpiDetailMode.value === 'logs') {
const r: any = await api.get('/data-quality/logs', {
params: { ...(KPI_DRILL[kpiDetailKey.value].params || {}), ...pageParams },
})
kpiDetailItems.value = r.data || []
kpiDetailTotal.value = r.total ?? 0
} else {
const r: any = await api.get('/data-quality/missing-data-kpis', { params: pageParams })
kpiDetailItems.value = r.items || []
kpiDetailTotal.value = r.total ?? 0
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '明细加载失败')
}
kpiDetailLoading.value = false
}
/** 导出当前口径全量明细(按 total 分批拉取后合并,前端 Blob 生成,不新增后端写接口) */
async function exportKpiDetailCsv() {
const key = kpiDetailKey.value
const mode = kpiDetailMode.value
kpiExporting.value = true
try {
const exportPageSize = 200 // 后端 page_size 上限
const base: any = mode === 'logs' ? { ...(KPI_DRILL[key].params || {}) } : {}
let rows: any[] = []
let total = 0
for (let p = 1; ; p++) {
const r: any = mode === 'logs'
? await api.get('/data-quality/logs', { params: { ...base, page: p, page_size: exportPageSize } })
: await api.get('/data-quality/missing-data-kpis', { params: { page: p, page_size: exportPageSize } })
total = r.total ?? 0
const batch: any[] = (mode === 'logs' ? r.data : r.items) || []
rows = rows.concat(batch)
if (!batch.length || rows.length >= total || p > 200) break
}
if (rows.length !== total) {
ElMessage.warning(`导出条数(${rows.length})与真实总数(${total})不一致,已按实际拉取结果导出`)
}
const header = mode === 'logs'
? ['KPI', '编码', '异常类型', '严重度', '详情', '建议操作', '状态', '发现时间']
: ['KPI', '编码', '负责人', '数据源', '单位']
const lines = [header.map(csvCell).join(',')]
for (const r of rows) {
const cells = mode === 'logs'
? [r.kpi_name, r.kpi_code, checkTypeText(r.check_type), r.severity, detailText(r),
r.suggestion, statusText(r.status), r.created_at]
: [r.kpi_name, r.kpi_code, r.data_owner, r.data_source, r.unit]
lines.push(cells.map(csvCell).join(','))
}
const d = new Date()
const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
const filename = `数据质量-${KPI_DRILL[key].label}-${key}-${stamp}.csv`
const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${rows.length} 条(全量 ${total} 条)`)
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '导出失败')
}
kpiExporting.value = false
}
// ── 明细列渲染:与既有「质量异常日志」列表同款文案(未知类型不臆造,回显原值)──
const CHECK_TYPE_TEXT: Record<string, string> = {
missing_data: '数据缺失', abnormal_change: '环比骤变', flat_data: '连续持平', value_outlier: '值异常',
}
const CHECK_TYPE_TAG: Record<string, string> = {
missing_data: 'danger', abnormal_change: 'warning',
}
function checkTypeText(t: string) {
return CHECK_TYPE_TEXT[t] || t || '-'
}
function checkTypeTag(t: string) {
return CHECK_TYPE_TAG[t] || 'info'
}
function detailText(row: any) {
const d = row?.detail || {}
if (row?.check_type === 'missing_data') return `缺失${d.missing_months}个月数据,最新: ${d.latest_period ?? '无'}`
if (row?.check_type === 'abnormal_change') return `变化率 ${d.change_pct}%,当前: ${d.current_value},上月: ${d.previous_value}`
if (row?.check_type === 'flat_data') return `连续3期持平: ${d.flat_value}`
if (row?.check_type === 'value_outlier') return `Z-Score: ${d.z_score},均值: ${d.mean}`
return JSON.stringify(d)
}
const STATUS_TEXT: Record<string, string> = { open: '未处理', resolved: '已解决', ignored: '已忽略' }
function statusText(s: string) {
return STATUS_TEXT[s] || s || '-'
}
function handleTabChange(name: string | number) {
// Tab2 懒加载:首次切换到"财务七规则"时才请求
if (name === 'gov' && !govLoaded.value) {
@@ -590,18 +901,26 @@ async function loadStats() {
try { const r: any = await dataQualityApi.stats(); stats.value = r } catch (e) {}
}
async function loadLogs() {
async function loadLogs(page?: number) {
if (typeof page === 'number') logPage.value = page
loading.value = true
try {
const params: any = {}
const params: any = { page: logPage.value, page_size: logPageSize }
if (filterSeverity.value) params.severity = filterSeverity.value
if (filterStatus.value) params.status = filterStatus.value
const r: any = await dataQualityApi.logs(params)
logs.value = r.data || []
logTotal.value = r.total ?? 0
} catch (e) { ElMessage.error('加载日志失败') }
loading.value = false
}
/** 筛选条件变更 → 回到第 1 页重查 */
function reloadLogs() {
logPage.value = 1
loadLogs()
}
async function runCheck() {
checking.value = true
try {
@@ -662,6 +981,16 @@ onMounted(() => {
.gov-count-link.is-zero { cursor: not-allowed; }
.gov-count-manual { font-size: 11px; color: #e6a23c; margin-left: 4px; cursor: pointer; }
.gov-count-manual:hover { text-decoration: underline; }
/* ── KPI质量监控卡片下钻 / 口径标注(2026-09-24)── */
.stat-value-link { cursor: pointer; border-bottom: 1px dashed transparent; }
.stat-value-link:hover { border-bottom-color: currentColor; }
.stat-value.is-zero { cursor: not-allowed; }
.dq-count-link { font-weight: 600; cursor: pointer; border-bottom: 1px dashed transparent; }
.dq-count-link:hover { border-bottom-color: currentColor; }
.dq-count-link.is-zero { cursor: not-allowed; font-weight: 400; }
.dq-card-caliber { font-size: 11px; color: #a8abb2; line-height: 1.6; margin-top: 8px; }
.dq-chart-hint { margin-top: 0; }
.dq-detail-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dq-detail-name { font-weight: 600; font-size: 14px; }
.dq-detail-code { font-size: 11px; color: #999; }