Files
cma-management/frontend/src/views/DataQuality.vue
T
wecom-fullstack 36f4f54799
ci/woodpecker/push/main Pipeline failed
feat(data-quality): 财务七规则可配置化最小实现(ASK-20260928-05)
任总 2026-09-28 13:38 放行「4 和 5」→ 落笔最小实现:
- 新表 quality_rule_config(39 项:37 规则级 + 2 全局)= 唯一配置源
- 三层解析:账套行 → 全账套模板(entity_id=0) → 代码内置默认(=清单 §7 建议值,零行为差异)
- 端点 /api/cma/data-quality/rule-config(GET/PUT)+ /history + /meta:写入仅 ceo/it,
  platform 级仅 it,演示账套(functional)写禁,变更落 operation_logs 同事务
- 数据质量页新增「规则配置」只读 Tab(懒加载)
- 唯一行为变更:污染判定去掉 sync —— 酣客 zxt-sync 系统迁入数据不再被误判(口径可配置)
- 出厂默认落库脚本 scripts/seed_dq_rule_config.py(--check/--apply/--rollback,幂等 39/0)
- 回归:新套件 35 例 + 存量 1508 passed(唯一 FAILED 为他人未完成
  migrate_subjects_entity_id_20260923.py 改动所致,HEAD 干净树同例通过)

生产写(DDL/seed)与 push 均走审批门:DDL=54434656 push=3bd00774
2026-09-28 13:56:16 +08:00

1189 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="flex-between section-gap">
<span class="page-title">数据质量监控</span>
</div>
<el-tabs v-model="activeTab" class="section-gap" @tab-change="handleTabChange">
<!-- ── Tab1: KPI质量监控 ── -->
<el-tab-pane label="KPI质量监控" name="kpi">
<div class="flex-between" style="margin-bottom:16px;">
<span style="color:#909399;font-size:13px;">KPI值质量检查(缺失/骤变/持平/离群)</span>
<div>
<el-button type="primary" @click="runCheck" :loading="checking">执行全量检查</el-button>
<el-button @click="loadLogs">刷新</el-button>
</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>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<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"
: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"
: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>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: completenessColor }">
{{ stats.completeness?.score ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
完整 {{ stats.completeness?.complete ?? 0 }} / 总计 {{ stats.completeness?.total ?? 0 }}
</div>
<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"
:color="completenessColor"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<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:
<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"
color="#e6a23c"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<!-- 天数唯一来源 = 接口 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超过 {{ 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>
<el-tag v-else type="success" size="small" style="margin-top:8px;">
数据更新正常
</el-tag>
</div>
</el-card>
</el-col>
</el-row>
<!-- 异常类型分布 -->
<el-row :gutter="16" class="section-gap">
<el-col :span="24">
<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>
<!-- 质量日志列表 -->
<el-card shadow="never">
<template #header>
<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="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="reloadLogs">
<el-option label="未处理" value="open" />
<el-option label="已处理" value="resolved" />
<el-option label="已忽略" value="ignored" />
</el-select>
</div>
</div>
</template>
<el-table :data="logs" size="small" border v-loading="loading">
<el-table-column prop="kpi_name" label="KPI" min-width="150" />
<el-table-column prop="kpi_code" label="编码" width="100" />
<el-table-column prop="check_type" label="异常类型" width="120">
<template #default="{ row }">
<el-tag :type="row.check_type === 'missing_data' ? 'danger' : row.check_type === 'abnormal_change' ? 'warning' : 'info'" size="small">
{{ row.check_type === 'missing_data' ? '数据缺失' : row.check_type === 'abnormal_change' ? '环比骤变' : row.check_type === 'flat_data' ? '连续持平' : '值异常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column 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 prop="detail" label="详情" min-width="250">
<template #default="{ row }">
<div v-if="row.check_type === 'missing_data'">缺失{{ row.detail?.missing_months }}个月数据,最新: {{ row.detail?.latest_period ?? '无' }}</div>
<div v-else-if="row.check_type === 'abnormal_change'">变化率 {{ row.detail?.change_pct }}%,当前: {{ row.detail?.current_value }},上月: {{ row.detail?.previous_value }}</div>
<div v-else-if="row.check_type === 'flat_data'">连续3期持平: {{ row.detail?.flat_value }}</div>
<div v-else-if="row.check_type === 'value_outlier'">Z-Score: {{ row.detail?.z_score }},均值: {{ row.detail?.mean }}</div>
<div v-else>{{ JSON.stringify(row.detail) }}</div>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议操作" min-width="200" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'open' ? 'danger' : row.status === 'resolved' ? 'success' : 'info'" size="small">
{{ row.status === 'open' ? '未处理' : row.status === 'resolved' ? '已解决' : '已忽略' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="发现时间" width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" type="success" plain @click="updateStatus(row.id, 'resolved')">已解决</el-button>
<el-button v-if="row.status === 'open'" size="small" @click="updateStatus(row.id, 'ignored')">忽略</el-button>
</template>
</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>
<!-- ── Tab2: 财务七规则 ── -->
<el-tab-pane label="财务七规则" name="gov">
<div v-if="govLoaded">
<el-card shadow="never" class="section-gap">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>🛡️ DAMA数据治理规则检查(财务七规则)</span>
<div style="display:flex;align-items:center;gap:8px;">
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<el-button type="primary" @click="runGovernanceCheck" :loading="govChecking">重新检查</el-button>
</div>
</div>
</template>
<el-row :gutter="16">
<!-- 评分大数字 -->
<el-col :span="6">
<div style="text-align:center;padding:16px 0;border-right:1px solid #f0f0f0;">
<div :style="{ fontSize: '56px', fontWeight: 700, lineHeight: 1.2, color: govScoreColor }">
{{ govScore }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">质量评分(满分100)</div>
<div style="font-size:12px;color:#999;margin-top:12px;line-height:1.8;">
检查时间: {{ govCheckedAt || '-' }}<br />
通过 {{ govPassedCount }} / 7 条规则<br />
共扣 {{ govTotalDeduct }} 分
</div>
</div>
</el-col>
<!-- 规则列表 -->
<el-col :span="18">
<el-table :data="govIssues" size="small" border>
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding:10px 18px;background:#fafafa;">
<div v-if="row.detail && row.detail.length" style="font-size:12px;line-height:24px;color:#606266;">
<div v-for="(d, i) in row.detail" :key="i">• {{ d }}</div>
</div>
<div v-else style="font-size:12px;color:#67c23a;">✓ 未发现异常</div>
<div v-if="row.manual_detail && row.manual_detail.length" style="font-size:12px;line-height:24px;color:#e6a23c;margin-top:4px;">
<div v-for="(d, i) in row.manual_detail" :key="'m'+i">⚠ {{ d }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="govRuleStatus(row)" size="small">{{ govRuleStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="规则" width="150">
<template #default="{ row }">
<span style="font-weight:600;">{{ govRuleName(row.rule) }}</span>
<div style="font-size:11px;color:#999;">{{ row.rule }}</div>
</template>
</el-table-column>
<el-table-column label="检查内容" min-width="240">
<template #default="{ row }">{{ govRuleDesc(row.rule) }}</template>
</el-table-column>
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="row.level === 'error' ? 'danger' : 'warning'" size="small">
{{ row.level === 'error' ? '异常' : '警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="异常数" width="120">
<template #default="{ row }">
<!-- 2026-09-23:异常数可点击下钻全量明细;=0 置灰不可点 -->
<span
class="gov-count-link"
:class="{ 'is-zero': !(row.count > 0) }"
:style="{ color: govCountColor(row) }"
:title="row.count > 0 ? '点击查看全部明细' : '无异常记录'"
@click="openGovDetail(row)"
>{{ row.count }}</span>
<span
v-if="row.manual_count"
class="gov-count-manual"
title="点击查看 manual 待人工确认明细"
@click="openGovDetail(row, 'manual')"
>(待确认{{ row.manual_count }})</span>
</template>
</el-table-column>
<el-table-column label="扣分" width="70">
<template #default="{ row }">
<span :style="{ color: row.deducted > 0 ? '#f56c6c' : '#909399' }">-{{ row.deducted ?? 0 }}</span>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</div>
</el-tab-pane>
<!-- ── Tab3: 规则配置(只读)— ASK-20260928-05 最小实现 ── -->
<el-tab-pane label="规则配置" name="rulecfg">
<div v-if="cfgLoaded">
<el-card shadow="never" class="section-gap">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>财务七规则配置(DAMA 口径清单 · 任务 ASK-20260928-05)</span>
<div style="display:flex;align-items:center;gap:8px;">
<el-tag size="small" type="info" effect="plain">只读</el-tag>
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<el-button @click="loadRuleConfig" :loading="cfgLoading">刷新</el-button>
</div>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="本轮为只读视图;写权限 ceo/it 经 PUT /api/cma/data-quality/rule-config(同一配置源)"
:description="cfgSummary"
/>
<el-table :data="cfgRows" size="small" border class="section-gap">
<el-table-column label="规则" width="150">
<template #default="{ row }">
<span style="font-weight:600;">{{ row.ruleName }}</span>
<div style="font-size:11px;color:#999;">{{ row.ruleKey }}</div>
</template>
</el-table-column>
<el-table-column label="参数" width="180">
<template #default="{ row }">
<span>{{ row.paramKey }}</span>
</template>
</el-table-column>
<el-table-column label="当前生效值" min-width="190">
<template #default="{ row }">
<span class="dq-kv">{{ row.valueText }}</span>
</template>
</el-table-column>
<el-table-column label="来源" width="120">
<template #default="{ row }">
<el-tag size="small" :type="row.sourceTag">{{ row.sourceText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开放级别" width="100">
<template #default="{ row }">{{ row.openLevelText }}</template>
</el-table-column>
<el-table-column label="最近修改" width="200">
<template #default="{ row }">{{ row.updatedText }}</template>
</el-table-column>
<el-table-column label="说明" min-width="260">
<template #default="{ row }">{{ row.desc }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-gap">
<template #header>
<span>锁死项(不可配置 — 防「治理规则被配置绕过」的 10 类)</span>
</template>
<el-table :data="cfgLocked" size="small" border>
<el-table-column label="锁死项" min-width="320">
<template #default="{ row }">{{ row.item }}</template>
</el-table-column>
<el-table-column label="理由" min-width="320">
<template #default="{ row }">{{ row.reason }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-gap">
<template #header>
<span>最近变更(operation_logs · 仅 ceo/it 可见)</span>
</template>
<el-table :data="cfgHistory" size="small" border>
<el-table-column label="时刻" width="180">
<template #default="{ row }">{{ (row.at || '').replace('T', ' ') }}</template>
</el-table-column>
<el-table-column label="参数" width="220">
<template #default="{ row }">{{ row.rule_key }}.{{ row.param_key }}</template>
</el-table-column>
<el-table-column label="改前 → 改后" min-width="220">
<template #default="{ row }">
{{ cfgFmt(row.before) }} → {{ cfgFmt(row.after) }}
</template>
</el-table-column>
<el-table-column label="理由" min-width="260">
<template #default="{ row }">{{ row.reason }}</template>
</el-table-column>
</el-table>
<div v-if="!cfgHistory.length" style="font-size:12px;color:#909399;margin-top:8px;">
暂无变更记录
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
<!-- 异常明细下钻(2026-09-23 P1):项目铁律 —— 一律用 MyDialog(Element 原生弹窗有已知 bug,禁用) -->
<MyDialog v-model="govDetailVisible" :title="govDetailTitle" :width="980">
<div class="dq-detail">
<div class="dq-detail-bar">
<el-tag :type="govDetail.level === 'error' ? 'danger' : 'warning'" size="small">
{{ govDetail.level === 'error' ? '异常' : '警告' }}
</el-tag>
<span class="dq-detail-name">{{ govDetail.name || govRuleName(govDetailRule) }}</span>
<span class="dq-detail-code">{{ govDetailRule }}</span>
<span class="dq-detail-total">真实总数 <b>{{ govDetail.total }}</b> 条</span>
<span v-if="govDetailScope === 'manual'" class="dq-detail-scope">口径:source='manual'(待人工确认组)</span>
<span v-else-if="govDetailRule === 'virtual_pollution' && govDetailManualTotal > 0" class="dq-detail-scope">
另有 manual 待确认 {{ govDetailManualTotal }} 条
</span>
<div style="flex:1"></div>
<el-switch
v-if="govDetailRule === 'virtual_pollution'"
v-model="govDetailManualSwitch"
size="small"
active-text="含 manual 待确认"
:disabled="govDetailLoading || govExporting"
@change="onGovDetailScopeChange"
/>
</div>
<div class="dq-detail-desc">口径说明:{{ govDetail.desc || govRuleDesc(govDetailRule) }}</div>
<el-table
v-if="govDetailItems.length"
:data="govDetailItems"
size="small"
border
max-height="420"
v-loading="govDetailLoading"
>
<el-table-column prop="record_id" label="记录ID" width="90" />
<el-table-column prop="entity_id" label="账套" width="70" />
<el-table-column prop="source" label="来源" width="130" />
<el-table-column label="关键字段" min-width="220">
<template #default="{ row }"><span class="dq-kv">{{ govKeyFields(row) }}</span></template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="250" />
<el-table-column prop="suggested_action" label="建议动作" min-width="200" />
</el-table>
<el-empty v-else-if="!govDetailLoading" description="该规则当前无异常记录" :image-size="80" />
<div class="dq-detail-foot">
<span class="dq-detail-hint">明细为服务端分页(每页 {{ govDetailPageSize }} 条,共 {{ govDetailPageCount }} 页)</span>
<div style="flex:1"></div>
<el-button size="small" :loading="govExporting" :disabled="govDetailLoading" @click="exportGovDetailCsv">
导出 CSV(全量 {{ govDetail.total }} 条)
</el-button>
<el-pagination
v-model:current-page="govDetailPage"
:page-size="govDetailPageSize"
:total="govDetail.total"
layout="prev, pager, next"
:disabled="govDetailLoading"
background
small
@current-change="fetchGovDetail"
/>
</div>
</div>
<template #footer>
<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>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import VChart from 'vue-echarts'
import 'echarts'
import api, { dataQualityApi } from '../api'
import { sessionEntityName } from '../utils/entity'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('kpi')
const govLoaded = ref(false)
const stats = ref<any>({})
const logs = ref<any[]>([])
const loading = ref(false)
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)
const govIssues = ref<any[]>([])
const govCheckedAt = ref('')
// 企业只读标识(账套由登录窗口选定,页内无切换入口)
const entityLabel = ref(sessionEntityName())
const govChecking = ref(false)
const govRuleMeta = ref<any>({})
// ── 异常明细下钻(2026-09-23 P1:异常数可点击看全量 + 计数口径修正)──
const govDetailVisible = ref(false)
const govDetailRule = ref('')
const govDetailScope = ref<'error' | 'manual'>('error') // virtual_pollution 专用
const govDetailManualSwitch = ref(false)
const govDetailManualTotal = ref(0)
const govDetailItems = ref<any[]>([])
const govDetail = ref<any>({ rule: '', name: '', level: '', desc: '', total: 0 })
const govDetailPage = ref(1)
const govDetailPageSize = 50 // 与后端契约一致(默认 50、上限 200)
const govDetailLoading = ref(false)
const govExporting = ref(false)
// ── 规则配置(只读)— ASK-20260928-05 ──
const cfgLoaded = ref(false)
const cfgLoading = ref(false)
const cfgData = ref<any>({})
const cfgHistory = ref<any[]>([])
function cfgFmt(v: any): string {
if (v === null || v === undefined) return '-'
if (Array.isArray(v)) return v.length ? v.join(', ') : '(空)'
if (typeof v === 'boolean') return v ? '是' : '否'
return String(v)
}
const SOURCE_TEXT: Record<string, string> = {
entity: '账套覆盖', template: '全账套模板', builtin: '内置默认', 'builtin(global)': '内置默认',
}
const cfgSummary = computed(() => {
const d: any = cfgData.value
if (!d || !d.entity_id) return ''
const parts = [
`配置表:${d.table_ready ? '已就绪' : '未建(按内置默认运行)'}`,
`配置指纹:${d.config_version || '-'}`,
`开放参数:${d.rule_param_count ?? 0} 项规则级 + ${Object.keys(d.global_params || {}).length} 项全局`,
`本账套可写:${d.entity_config_enabled ? '是' : '否(演示/测试账套只读)'}`,
]
return parts.join(' · ')
})
const cfgRows = computed(() => {
const d: any = cfgData.value
const out: any[] = []
if (!d || !d.rules) return out
for (const ruleKey of Object.keys(d.rules)) {
const rule = d.rules[ruleKey]
for (const paramKey of Object.keys(rule.params || {})) {
const p = rule.params[paramKey]
out.push({
ruleKey, ruleName: rule.name, paramKey,
valueText: cfgFmt(p.value),
sourceText: SOURCE_TEXT[p.source] || p.source,
sourceTag: p.source === 'entity' ? 'warning' : (p.source === 'template' ? 'success' : 'info'),
openLevelText: p.open_level === 'platform' ? '平台级(仅it)' : '账套级(ceo/it)',
updatedText: p.updated_at ? `${(p.updated_at || '').replace('T', ' ')}${p.updated_by ? ' · ' + p.updated_by : ''}` : '-',
desc: p.desc || '',
})
}
}
for (const paramKey of Object.keys(d.global_params || {})) {
const p = d.global_params[paramKey]
out.push({
ruleKey: 'global', ruleName: '全局项', paramKey,
valueText: cfgFmt(p.value),
sourceText: SOURCE_TEXT[p.source] || p.source,
sourceTag: p.source === 'entity' ? 'warning' : (p.source === 'template' ? 'success' : 'info'),
openLevelText: p.open_level === 'platform' ? '平台级(仅it)' : '账套级(ceo/it)',
updatedText: p.updated_at ? `${(p.updated_at || '').replace('T', ' ')}${p.updated_by ? ' · ' + p.updated_by : ''}` : '-',
desc: p.desc || '',
})
}
return out
})
const cfgLocked = computed(() => cfgData.value?.locked || [])
async function loadRuleConfig() {
cfgLoading.value = true
try {
cfgData.value = await api.get('/data-quality/rule-config')
// 变更历史仅 ceo/it 可见:无权限时静默留空,不影响只读视图
try {
const h: any = await api.get('/data-quality/rule-config/history', { params: { limit: 10 } })
cfgHistory.value = h.items || []
} catch (e) {
cfgHistory.value = []
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '加载规则配置失败')
}
cfgLoading.value = 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: 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}' },
}],
}))
const completenessColor = computed(() => {
const s = stats.value.completeness?.score ?? 0
if (s >= 80) return '#67c23a'
if (s >= 50) return '#e6a23c'
return '#f56c6c'
})
const staleDataColor = computed(() => {
const c = stats.value.stale_data?.count ?? 0
if (c === 0) return '#67c23a'
if (c <= 5) return '#e6a23c'
return '#f56c6c'
})
// ── DAMA治理规则(财务七规则)computed ──
const govPassedCount = computed(() => govIssues.value.filter((i: any) => i.count === 0).length)
const govScoreColor = computed(() => {
if (govScore.value >= 90) return '#67c23a'
if (govScore.value >= 70) return '#e6a23c'
return '#f56c6c'
})
function govCountColor(row: any) {
if (row.count > 0) return '#f56c6c'
if ((row.manual_count ?? 0) > 0) return '#e6a23c'
return '#67c23a'
}
function govRuleStatus(row: any) {
if (row.count > 0) return row.level === 'error' ? 'danger' : 'warning'
if ((row.manual_count ?? 0) > 0) return 'warning' // 待人工确认
return 'success'
}
function govRuleStatusText(row: any) {
if (row.count > 0) return row.level === 'error' ? '异常' : '警告'
if ((row.manual_count ?? 0) > 0) return '待确认'
return '通过'
}
function govRuleName(rule: string) {
return govRuleMeta.value?.[rule]?.name || rule
}
function govRuleDesc(rule: string) {
return govRuleMeta.value?.[rule]?.desc || ''
}
// ── 异常明细下钻 computed ──
const govDetailTitle = computed(() => `异常明细 · ${govDetail.value.name || govRuleName(govDetailRule.value)}`)
const govDetailPageCount = computed(() => Math.max(1, Math.ceil((govDetail.value.total || 0) / govDetailPageSize)))
/** 关键字段 dict → 一行可读文本(数组/对象做展开) */
function govKeyFields(row: any) {
const kf = row?.key_fields || {}
return Object.keys(kf).map((k) => {
let v: any = kf[k]
if (v === null || v === undefined || v === '') v = '-'
else if (Array.isArray(v)) v = v.join('/')
else if (typeof v === 'object') v = JSON.stringify(v)
return `${k}:${v}`
}).join(';')
}
/** 点「异常数」(或 manual 待确认)→ 打开明细;0 值不可点 */
function openGovDetail(row: any, scope?: 'manual') {
const manual = scope === 'manual'
if (manual ? !(row.manual_count > 0) : !(row.count > 0)) return
govDetailRule.value = row.rule
govDetailScope.value = manual ? 'manual' : 'error'
govDetailManualSwitch.value = manual
govDetailManualTotal.value = row.manual_count || 0
govDetailPage.value = 1
govDetailItems.value = []
govDetail.value = {
rule: row.rule, name: govRuleName(row.rule), level: row.level,
desc: govRuleDesc(row.rule), total: 0,
}
govDetailVisible.value = true
fetchGovDetail()
}
/** virtual_pollution:「含 manual 待确认」开关(后端 scope=manual) */
function onGovDetailScopeChange(val: any) {
govDetailScope.value = val ? 'manual' : 'error'
govDetailPage.value = 1
govDetailItems.value = []
fetchGovDetail()
}
/** 明细拉取(服务端分页;账套由会话 token 绑定,前端不传 entity_id) */
async function fetchGovDetail(page?: number) {
if (!govDetailRule.value) return
if (typeof page === 'number') govDetailPage.value = page
govDetailLoading.value = true
try {
const params: any = {
rule: govDetailRule.value,
page: govDetailPage.value,
page_size: govDetailPageSize,
}
if (govDetailRule.value === 'virtual_pollution' && govDetailScope.value === 'manual') params.scope = 'manual'
const r: any = await api.get('/data-quality/rule-detail', { params })
govDetail.value = { rule: r.rule, name: r.name, level: r.level, desc: r.desc, total: r.total }
govDetailItems.value = r.items || []
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '明细加载失败')
}
govDetailLoading.value = false
}
function csvCell(v: any) {
const s = v === null || v === undefined ? '' : String(v)
return '"' + s.replace(/"/g, '""') + '"'
}
/** 导出当前规则全量明细(按 total 分批拉取后合并,前端 Blob 生成,不新增后端写接口) */
async function exportGovDetailCsv() {
if (!govDetailRule.value) return
govExporting.value = true
try {
const exportPageSize = 200 // 后端 page_size 上限
const base: any = { rule: govDetailRule.value, page_size: exportPageSize }
if (govDetailRule.value === 'virtual_pollution' && govDetailScope.value === 'manual') base.scope = 'manual'
const first: any = await api.get('/data-quality/rule-detail', { params: { ...base, page: 1 } })
const total = first.total || 0
let rows: any[] = first.items || []
const pages = Math.ceil(total / exportPageSize)
for (let p = 2; p <= pages; p++) {
const r: any = await api.get('/data-quality/rule-detail', { params: { ...base, page: p } })
rows = rows.concat(r.items || [])
}
if (rows.length !== total) {
ElMessage.warning(`导出条数(${rows.length})与真实总数(${total})不一致,已按实际拉取结果导出`)
}
const header = ['记录ID', '账套', '来源', '关键字段', '摘要', '建议动作']
const lines = [header.map(csvCell).join(',')]
for (const r of rows) {
lines.push([r.record_id, r.entity_id, r.source, govKeyFields(r), r.summary, r.suggested_action].map(csvCell).join(','))
}
const scopeTag = govDetailScope.value === 'manual' ? '-manual' : ''
const d = new Date()
const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
const filename = `数据质量-${govDetail.value.name || govDetailRule.value}-${govDetailRule.value}${scopeTag}-${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 || '导出失败')
}
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) {
govLoaded.value = true
runGovernanceCheck()
}
// Tab3 懒加载:首次切换到"规则配置"(只读)时才请求
if (name === 'rulecfg' && !cfgLoaded.value) {
cfgLoaded.value = true
loadRuleConfig()
}
}
async function loadStats() {
try { const r: any = await dataQualityApi.stats(); stats.value = r } catch (e) {}
}
async function loadLogs(page?: number) {
if (typeof page === 'number') logPage.value = page
loading.value = true
try {
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 {
const r: any = await dataQualityApi.check()
ElMessage.success(`检查完成: ${r.issues_found}个问题, 新增${r.new_logs}条记录`)
await loadStats()
await loadLogs()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '检查失败')
}
checking.value = false
}
async function runGovernanceCheck() {
govChecking.value = true
try {
// 会话账套:不再传页面级 entity_id(后端 assert_entity_scope 按 token 绑定账套取数)
const r: any = await dataQualityApi.governanceCheck()
govScore.value = r.score ?? 0
govTotalDeduct.value = r.total_deduct ?? 0
govIssues.value = r.issues || []
govCheckedAt.value = (r.checked_at || '').replace('T', ' ').slice(0, 19)
govRuleMeta.value = r.rules_meta || {}
const bad = (r.issues || []).filter((i: any) => i.count > 0).length
ElMessage.success(`治理检查完成: 评分${r.score}分, 异常规则${bad}条`)
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '治理检查失败')
}
govChecking.value = false
}
async function updateStatus(id: number, status: string) {
try {
await dataQualityApi.updateLog(id, { status })
ElMessage.success('更新成功')
await loadLogs()
await loadStats()
} catch (e) { ElMessage.error('更新失败') }
}
onMounted(() => {
loadStats()
loadLogs()
// Tab2 懒加载:不主动请求 governance-check,切换到 Tab2 时触发
})
</script>
<style scoped>
.quality-page { }
.stat-card-inner { text-align: center; }
.stat-card-inner .stat-value { font-size: 28px; font-weight: 600; }
.stat-card-inner .stat-label { font-size: 13px; color: #999; }
.section-gap-tabs { margin-top: 16px; }
/* ── 异常数下钻(2026-09-23)── */
.gov-count-link { cursor: pointer; font-weight: 600; border-bottom: 1px dashed transparent; }
.gov-count-link:hover { border-bottom-color: currentColor; }
.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; }
.dq-detail-total { font-size: 13px; }
.dq-detail-scope { font-size: 12px; color: #e6a23c; }
.dq-detail-desc { font-size: 12px; color: #606266; margin: 6px 0 12px; line-height: 1.6; }
.dq-kv { font-size: 12px; color: #606266; word-break: break-all; }
.dq-detail-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.dq-detail-hint { font-size: 12px; color: #909399; }
</style>