ci/woodpecker/push/main Pipeline failed
任总 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
1189 lines
55 KiB
Vue
1189 lines
55 KiB
Vue
<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>
|