From 72d4d97c836195359133ec45c1ad503cf5b05b5d Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Thu, 24 Sep 2026 00:40:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(data-quality):=20KPI=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E9=A1=B5=20=E2=80=94=20=E8=B4=A6=E5=A5=97?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E6=94=B6=E5=8F=A3=20+=20=E7=8E=AF=E5=9B=BE?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E7=9C=9F=E5=80=BC=20+=20=E5=A4=A9=E6=95=B0?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E6=9D=A5=E6=BA=90=20+=20=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=95=B0=E4=B8=8B=E9=92=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 端点)。 --- backend/app/api/data_quality.py | 116 +++++++-- frontend/src/views/DataQuality.vue | 373 +++++++++++++++++++++++++++-- 2 files changed, 448 insertions(+), 41 deletions(-) diff --git a/backend/app/api/data_quality.py b/backend/app/api/data_quality.py index d705f060..4caa6270 100644 --- a/backend/app/api/data_quality.py +++ b/backend/app/api/data_quality.py @@ -31,6 +31,30 @@ def _log_to_dict(log): return d +# ── 陈旧数据阈值(单一来源 2026-09-24)── +# 阈值本身维持 180 天不变(改阈值属口径变更,须另行拍板);本常量只解决 +# 「同一张卡片标题写30天、副文案写180天」的口径漂移:接口与前端文案一律引用本值。 +STALE_THRESHOLD_DAYS = 180 + +# 质量日志分页契约(默认 50/页、上限 200 —— 与 /rule-detail 同规格) +LOG_PAGE_SIZE_DEFAULT = 50 +LOG_PAGE_SIZE_MAX = 200 + + +def _scoped_quality_log_query(db: Session, entity_id: int): + """质量日志「按会话账套收口」的唯一口径(/logs 与 /stats 共用,防两处漂移) + + kpi_data_quality_log 表**无 entity_id 列**(本单禁止加列)⇒ 经 kpi_id + join kpi_definitions,取 kpi_definitions.entity_id 判定归属。 + 内连接语义:kpi_id 无对应 KPI 行的孤儿日志无法归属账套 ⇒ 一律不计(不猜账套)。 + """ + return ( + db.query(KpiDataQualityLog) + .join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id) + .filter(KPIDefinition.entity_id == entity_id) + ) + + # ============================================================ # 质量检查 # ============================================================ @@ -176,20 +200,22 @@ def list_quality_logs( severity: Optional[str] = None, check_type: Optional[str] = None, status: Optional[str] = None, + page: int = Query(1, ge=1, description="页码,从 1 开始(下钻与服务端分页)"), + page_size: int = Query(LOG_PAGE_SIZE_DEFAULT, ge=1, le=LOG_PAGE_SIZE_MAX, + description="每页条数,默认 50,上限 200"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): - """获取数据质量日志(账套隔离 2026-09-21) + """获取数据质量日志(账套隔离 2026-09-21;服务端分页 + 真实 total 2026-09-24) kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1) 经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。 join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。 + + `total` = 同筛选条件下的**真实总数**(COUNT(*),不受分页影响)——卡片数字 + (/stats)与列表 total 共用 `_scoped_quality_log_query` 同一口径,故二者必然自洽。 """ - query = ( - db.query(KpiDataQualityLog) - .join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id) - .filter(KPIDefinition.entity_id == entity_id) - ) + query = _scoped_quality_log_query(db, entity_id) if kpi_id: query = query.filter(KpiDataQualityLog.kpi_id == kpi_id) if severity: @@ -199,7 +225,11 @@ def list_quality_logs( if status: query = query.filter(KpiDataQualityLog.status == status) - logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all() + total = query.count() + logs = ( + query.order_by(KpiDataQualityLog.created_at.desc(), KpiDataQualityLog.id.desc()) + .limit(page_size).offset((page - 1) * page_size).all() + ) result = [] for log in logs: d = _log_to_dict(log) @@ -208,7 +238,7 @@ def list_quality_logs( d["kpi_code"] = kpi.kpi_code d["kpi_name"] = kpi.kpi_name result.append(d) - return {"data": result, "total": len(result)} + return {"data": result, "total": total, "page": page, "page_size": page_size} def _scoped_quality_log(db: Session, log_id: int, entity_id: int): @@ -258,28 +288,36 @@ def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_RO @router.get("/stats") def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): - """数据质量统计(账套隔离 2026-09-21:KPI 口径按会话账套;质量日志表无 entity_id 列,见回报)""" + """数据质量统计(账套隔离 2026-09-21;日志四项计数收口 2026-09-24) + + 口径(与 /logs 共用 `_scoped_quality_log_query`,故卡片数字与列表 total 必然自洽): + * total_logs / open_logs / severity_counts / type_counts:质量日志**本账套**计数 + (kpi_data_quality_log 无 entity_id 列 ⇒ 经 kpi_id join kpi_definitions 归属) + * 层级:异常类四项为**记录数**(条),total_kpis 与完整性/缺失率为**实体数**(个) + """ total_kpis = db.query(KPIDefinition).filter( KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id, ).count() - total_logs = db.query(KpiDataQualityLog).count() - open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count() + # 账套收口:本账套(内连接;孤儿日志不计) + total_logs = _scoped_quality_log_query(db, entity_id).count() + open_logs = _scoped_quality_log_query(db, entity_id).filter( + KpiDataQualityLog.status == "open").count() - # 按严重程度统计 + # 按严重程度统计(本账套) severity_counts = {} for s in ("info", "warning", "critical"): - cnt = db.query(KpiDataQualityLog).filter( + cnt = _scoped_quality_log_query(db, entity_id).filter( KpiDataQualityLog.severity == s, KpiDataQualityLog.status == "open", ).count() if cnt: severity_counts[s] = cnt - # 按检查类型统计 + # 按检查类型统计(本账套) type_counts = {} for t in ("abnormal_change", "flat_data", "missing_data", "value_outlier"): - cnt = db.query(KpiDataQualityLog).filter( + cnt = _scoped_quality_log_query(db, entity_id).filter( KpiDataQualityLog.check_type == t, KpiDataQualityLog.status == "open", ).count() @@ -299,7 +337,7 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en stale_data_count = 0 from datetime import datetime, timedelta - six_months_ago = datetime.now() - timedelta(days=180) + stale_threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS) for kpi in all_kpis: # 元数据完整度检查 @@ -323,13 +361,13 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en if not val: missing_data_count += 1 - # 超30天未更新预警 + # 超 STALE_THRESHOLD_DAYS 天未更新预警(阈值单一来源,勿在注释/文案里另写天数) latest_val = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).first() if latest_val and latest_val.calculated_at: - if latest_val.calculated_at < six_months_ago: + if latest_val.calculated_at < stale_threshold: stale_data_count += 1 completeness_score = round(complete_kpis / total * 100, 1) if total > 0 else 0 @@ -355,11 +393,51 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en }, "stale_data": { "count": stale_data_count, - "threshold_days": 180, + "threshold_days": STALE_THRESHOLD_DAYS, }, } +# 数据缺失(data_missing)口径的 KPI 明细 —— 下钻用(只读,2026-09-24) +# 口径与上面 quality_stats() 的 data_missing 计数逐字一致: +# active KPI 且 kpi_values 中不存在 actual_value 非空的行 +MISSING_DATA_CALIBER = ("active KPI 且无任何实际值" + "(kpi_values.actual_value 非空的行为 0)") + + +@router.get("/missing-data-kpis") +def missing_data_kpis( + page: int = Query(1, ge=1, description="页码,从 1 开始"), + page_size: int = Query(LOG_PAGE_SIZE_DEFAULT, ge=1, le=LOG_PAGE_SIZE_MAX, + description="每页条数,默认 50,上限 200"), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """「缺失率统计」卡片下钻(只读):无任何实际值的 active KPI 清单 + + - 账套由会话 token 解析(`Depends(get_entity_id)`),**不新增账套入参**; + - `total` = 同口径 COUNT(*)(不受分页影响),与 `/stats.data_missing.count` 必然相等; + - 列表字段:KPI 编码 / 名称 / 负责人 / 数据源 / 单位(不含值明细,概览用)。 + """ + sql = ("SELECT d.id, d.kpi_code, d.kpi_name, d.data_owner, d.data_source, d.unit " + "FROM kpi_definitions d " + "WHERE d.status = 'active' AND d.entity_id = :eid " + "AND NOT EXISTS (SELECT 1 FROM kpi_values v " + "WHERE v.kpi_id = d.id AND v.actual_value IS NOT NULL)") + total = int(db.execute(text("SELECT COUNT(*) FROM (" + sql + ") t"), + {"eid": entity_id}).scalar() or 0) + rows = db.execute( + text(sql + " ORDER BY d.id LIMIT :lim OFFSET :off"), + {"eid": entity_id, "lim": page_size, "off": (page - 1) * page_size}, + ).fetchall() + items = [{ + "kpi_id": r.id, "kpi_code": r.kpi_code, "kpi_name": r.kpi_name, + "data_owner": r.data_owner, "data_source": r.data_source, "unit": r.unit, + } for r in rows] + return {"caliber": MISSING_DATA_CALIBER, "total": total, "page": page, + "page_size": page_size, "items": items} + + # ============================================================ # DAMA 数据治理规则检查(财务七规则)— 2026-08-30 P1 # ============================================================ diff --git a/frontend/src/views/DataQuality.vue b/frontend/src/views/DataQuality.vue index 28250d54..8907f80b 100644 --- a/frontend/src/views/DataQuality.vue +++ b/frontend/src/views/DataQuality.vue @@ -15,47 +15,65 @@ - +
{{ stats.total_kpis }}
-
KPI总数
+
KPI总数(实体数)
-
{{ stats.open_logs }}
-
未解决异常
+
{{ stats.open_logs ?? 0 }}
+
未解决异常(记录数)
-
{{ stats.severity_counts?.critical ?? 0 }}
-
严重异常
+
{{ stats.severity_counts?.critical ?? 0 }}
+
严重异常(记录数)
-
{{ stats.type_counts?.missing_data ?? 0 }}
-
数据缺失
+
{{ stats.type_counts?.missing_data ?? 0 }}
+
数据缺失(记录数)
- + - +
{{ stats.completeness?.score ?? '-' }}% @@ -66,6 +84,7 @@
缺失元数据: {{ stats.completeness?.missing_metadata ?? 0 }} 个KPI
+
口径:元数据五项(公式/数据源/负责人/单位/目标值)齐全的 KPI 占比(实体数)
- +
{{ stats.data_missing?.rate ?? '-' }}%
- 无数据值的KPI: {{ stats.data_missing?.count ?? 0 }} + 无数据值的KPI: + {{ stats.data_missing?.count ?? 0 }}
总计 {{ stats.data_missing?.total ?? 0 }} 个KPI
+
口径:active KPI 中无任何实际值(kpi_values.actual_value 非空行为 0)的占比(实体数)
- + +
{{ stats.stale_data?.count ?? 0 }}
- 个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新 + 个KPI超过 {{ staleThresholdDays }} 天未更新
+
口径:最近一次有效值入库时间早于阈值天数的 KPI(实体数)
建议立即检查数据源 @@ -124,6 +152,10 @@ +
+ 口径:本账套 status=open 记录中 4 类 KPI 值检查(数据缺失/环比骤变/连续持平/值异常)的条数; + 合计可能小于「未解决异常」总数(另有其它检查类型,未纳入本图)。 +
@@ -134,12 +166,12 @@
质量异常日志
- + - + @@ -188,6 +220,21 @@ + +
+ 共 {{ logTotal }} 条(本账套,服务端分页每页 {{ logPageSize }} 条) +
+ +
@@ -357,6 +404,85 @@ 关闭 + + + +
+
+ {{ kpiDetailMode === 'logs' ? '记录数(条)' : '实体数(个)' }} + {{ kpiDetailLabel }} + 真实总数 {{ kpiDetailTotal }} 条 +
+ + 导出 CSV(全量 {{ kpiDetailTotal }} 条) + +
+
口径说明:{{ kpiDetailDesc }}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 明细为服务端分页(每页 {{ kpiDetailPageSize }} 条,共 {{ kpiDetailPageCount }} 页) +
+ +
+
+ +
@@ -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 = { + 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('open') +const kpiDetailItems = ref([]) +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 = { + missing_data: '数据缺失', abnormal_change: '环比骤变', flat_data: '连续持平', value_outlier: '值异常', +} +const CHECK_TYPE_TAG: Record = { + 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 = { 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; }