- 个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新
+ 个KPI超过 {{ staleThresholdDays }} 天未更新
质量异常日志
-
+
-
+
@@ -188,6 +220,21 @@
+
+
@@ -357,6 +404,85 @@
关闭
+
+
+
+
+
+
{{ kpiDetailMode === 'logs' ? '记录数(条)' : '实体数(个)' }}
+
{{ kpiDetailLabel }}
+
真实总数 {{ kpiDetailTotal }} 条
+
+
+ 导出 CSV(全量 {{ kpiDetailTotal }} 条)
+
+
+
口径说明:{{ kpiDetailDesc }}
+
+
+
+
+
+
+
+ {{ checkTypeText(row.check_type) }}
+
+
+
+
+ {{ row.severity }}
+
+
+
+
+ {{ detailText(row) }}
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 关闭
+
+
@@ -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; }