feat(data-quality): anomaly-count drilldown to full detail + real COUNT(*) caliber
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
backend: GET /api/cma/data-quality/rule-detail (COUNT(*) total, structured items, server-side paging, scope=manual); governance seven-rule counts use real totals instead of len(LIMIT-ed rows). frontend: clickable anomaly count -> MyDialog detail (no new el-dialog) + full CSV export.
This commit is contained in:
@@ -259,10 +259,22 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常数" width="90">
|
||||
<el-table-column label="异常数" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: govCountColor(row), fontWeight: 600 }">{{ row.count }}</span>
|
||||
<span v-if="row.manual_count" style="font-size:11px;color:#e6a23c;margin-left:4px;">(待确认{{ row.manual_count }})</span>
|
||||
<!-- 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">
|
||||
@@ -277,6 +289,74 @@
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -285,8 +365,9 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import VChart from 'vue-echarts'
|
||||
import 'echarts'
|
||||
import { dataQualityApi } from '../api/index'
|
||||
import api, { dataQualityApi } from '../api'
|
||||
import { sessionEntityName } from '../utils/entity'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('kpi')
|
||||
const govLoaded = ref(false)
|
||||
@@ -308,6 +389,19 @@ 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)
|
||||
|
||||
const typeChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
@@ -372,6 +466,118 @@ 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
|
||||
}
|
||||
|
||||
function handleTabChange(name: string | number) {
|
||||
// Tab2 懒加载:首次切换到"财务七规则"时才请求
|
||||
if (name === 'gov' && !govLoaded.value) {
|
||||
@@ -449,4 +655,20 @@ onMounted(() => {
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user