fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed
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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user