Files
cma-management/frontend/src/views/DataQuality.vue
T

282 lines
11 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-button type="primary" @click="runCheck" :loading="checking">执行全量检查</el-button>
<el-button @click="loadLogs">刷新</el-button>
</div>
</div>
<!-- 统计卡片 -->
<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" style="color:var(--bsc-learning);">{{ stats.open_logs }}</div>
<div class="stat-label">未解决异常</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#f56c6c;">{{ stats.severity_counts?.critical ?? 0 }}</div>
<div class="stat-label">严重异常</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#67c23a;">{{ stats.type_counts?.missing_data ?? 0 }}</div>
<div class="stat-label">数据缺失</div>
</div>
</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>
<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>📉 缺失率统计</template>
<div style="text-align:center;padding:12px 0;">
<div style="font-size:36px;font-weight:700;color:#e6a23c;">
{{ stats.data_missing?.rate ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
无数据值的KPI: {{ stats.data_missing?.count ?? 0 }}
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">
总计 {{ stats.data_missing?.total ?? 0 }} 个KPI
</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">
<template #header>⚠️ 超30天未更新预警</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: staleDataColor }">
{{ stats.stale_data?.count ?? 0 }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新
</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;" />
</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="loadLogs">
<el-option label="严重" value="critical" />
<el-option label="警告" value="warning" />
<el-option label="一般" value="info" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width:120px;" @change="loadLogs">
<el-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="暂无质量异常日志" />
</el-card>
</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 { dataQualityApi } from '../api/index'
const stats = ref<any>({})
const logs = ref<any[]>([])
const loading = ref(false)
const checking = ref(false)
const filterSeverity = ref('')
const filterStatus = ref('')
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' } },
].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'
})
async function loadStats() {
try { const r: any = await dataQualityApi.stats(); stats.value = r } catch (e) {}
}
async function loadLogs() {
loading.value = true
try {
const params: any = {}
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 || []
} catch (e) { ElMessage.error('加载日志失败') }
loading.value = false
}
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 updateStatus(id: number, status: string) {
try {
await dataQualityApi.updateLog(id, { status })
ElMessage.success('更新成功')
await loadLogs()
await loadStats()
} catch (e) { ElMessage.error('更新失败') }
}
onMounted(() => {
loadStats()
loadLogs()
})
</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; }
</style>