fix: 自动创建KPI时补全所有NOT NULL字段(formula/data_source/data_owner/unit/entity_id)

This commit is contained in:
Hermes CI Fix
2026-07-29 18:07:57 +08:00
parent 9b973ab9a8
commit 47ef861488
6 changed files with 626 additions and 1 deletions
+7
View File
@@ -293,4 +293,11 @@ export const botKpiApi = {
updateValue: (id: number, data: any) => api.post(`/bot-kpis/${id}/value`, data),
}
export const analysisApi = {
getResults: (params?: any) => api.get('/analysis/result', { params }),
createResult: (params: any) => api.post('/analysis/result', null, { params }),
deleteResult: (id: number) => api.delete(`/analysis/result/${id}`),
autoCalculate: (params?: any) => api.post('/analysis/auto-calculate', null, { params }),
}
export default api
+407
View File
@@ -0,0 +1,407 @@
<template>
<div class="confidence-page">
<!-- 页面Header -->
<div class="page-header">
<div class="header-left">
<h3><el-icon :size="22"><DataAnalysis /></el-icon> 分析结论置信度</h3>
<el-tag type="warning" size="small" effect="plain">{{ currentPeriod }}</el-tag>
</div>
<div class="header-right">
<el-input
v-model="searchPeriod"
placeholder="期间 (YYYY-MM)"
clearable
style="width: 180px; margin-right: 8px;"
@keyup.enter="loadResults"
/>
<el-button type="primary" size="small" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon> 新增分析
</el-button>
<el-button size="small" @click="loadResults" :loading="loading">刷新</el-button>
</div>
</div>
<!-- 置信度统计概览 -->
<div class="stats-cards">
<div class="stat-card">
<div class="stat-num">{{ totalResults }}</div>
<div class="stat-label">分析结论总数</div>
</div>
<div class="stat-card">
<div class="stat-num" style="color: #67c23a;">{{ highConfidenceCount }}</div>
<div class="stat-label">高置信度 85%</div>
</div>
<div class="stat-card">
<div class="stat-num" style="color: #e6a23c;">{{ midConfidenceCount }}</div>
<div class="stat-label">中等置信度 50-70%</div>
</div>
<div class="stat-card">
<div class="stat-num" style="color: #f56c6c;">{{ lowConfidenceCount }}</div>
<div class="stat-label">低置信度 &lt;50%</div>
</div>
</div>
<!-- 置信度评分说明 -->
<el-card class="legend-card" shadow="never">
<div class="legend-title">📊 置信度评分标准</div>
<div class="legend-grid">
<div class="legend-item" v-for="item in confidenceLevels" :key="item.level">
<el-tag :type="item.type" size="small">{{ item.level }}</el-tag>
<span class="legend-desc">{{ item.desc }}</span>
</div>
</div>
</el-card>
<!-- 分析结论列表 -->
<div class="results-list" v-loading="loading">
<el-empty v-if="results.length === 0 && !loading" description="暂无分析结论,点击「新增分析」添加" />
<el-card v-for="r in results" :key="r.id" class="result-card" shadow="hover">
<div class="result-head">
<div class="result-meta">
<el-tag size="small" effect="plain">{{ r.period }}</el-tag>
<span class="kpi-tag" v-if="r.kpi_name">{{ r.kpi_name }}</span>
</div>
<div class="result-actions">
<el-button size="small" text type="danger" @click="confirmDelete(r)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="result-body">
<div class="conclusion-text">{{ r.结论 }}</div>
<div class="confidence-bar-wrap">
<span class="confidence-label">置信度</span>
<el-progress
:percentage="parseInt(r.置信度)"
:color="confidenceColor(parseInt(r.置信度))"
:stroke-width="16"
:text-inside="true"
:format="() => r.置信度"
/>
</div>
<div class="detail-grid">
<div class="detail-item" v-if="r.数据来源">
<span class="detail-label">📁 数据来源</span>
<span class="detail-val">{{ r.数据来源 }}</span>
</div>
<div class="detail-item" v-if="r.计算逻辑">
<span class="detail-label">🧮 计算逻辑</span>
<span class="detail-val">{{ r.计算逻辑 }}</span>
</div>
<div class="detail-item" v-if="r.可比基准">
<span class="detail-label">📊 可比基准</span>
<span class="detail-val">{{ r.可比基准 }}</span>
</div>
<div class="detail-item" v-if="r.局限">
<span class="detail-label"> 局限</span>
<span class="detail-val limitation">{{ r.局限 }}</span>
</div>
</div>
<div class="data-flags">
<el-tag
v-for="flag in dataFlags(r)"
:key="flag.label"
:type="flag.active ? 'success' : 'info'"
size="small"
effect="plain"
>
{{ flag.active ? '✅' : '⬜' }} {{ flag.label }}
</el-tag>
</div>
</div>
</el-card>
</div>
<!-- 新增分析结论对话框 -->
<el-dialog v-model="showCreateDialog" title="新增分析结论" width="640px" :close-on-click-modal="false">
<el-form :model="form" label-width="100px" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="期间" required>
<el-input v-model="form.period" placeholder="YYYY-MM" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI编码">
<el-input v-model="form.kpi_code" placeholder="如 FB_CHANNEL_RATE" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分析结论" required>
<el-input v-model="form.conclusion" type="textarea" :rows="2" placeholder="输入分析结论内容" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="数据来源">
<el-input v-model="form.data_source" placeholder="如: 科目余额表·本期发生列" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可比基准">
<el-input v-model="form.comparable_benchmark" placeholder="如: H1渠补率82.8%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="计算逻辑">
<el-input v-model="form.calculation_logic" type="textarea" :rows="2" placeholder="如: 捐赠支出682,597 / 收入1,293,236 = 52.8%" />
</el-form-item>
<el-form-item label="局限说明">
<el-input v-model="form.limitations" type="textarea" :rows="2" placeholder="如: 6月单月可能受季节性影响" />
</el-form-item>
<el-divider>数据完备度决定置信度</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-checkbox v-model="form.has_actual">有实际值</el-checkbox>
</el-col>
<el-col :span="6">
<el-checkbox v-model="form.has_target">有目标值</el-checkbox>
</el-col>
<el-col :span="6">
<el-checkbox v-model="form.has_trend">有历史趋势</el-checkbox>
</el-col>
<el-col :span="6">
<el-checkbox v-model="form.has_review">人工复核</el-checkbox>
</el-col>
</el-row>
<div class="calc-preview">
根据所选数据完备度置信度将自动计算为
<el-tag :type="previewConfidence >= 85 ? 'success' : previewConfidence >= 50 ? 'warning' : 'danger'" effect="dark" class="preview-tag">
{{ previewConfidence }}%
</el-tag>
</div>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="submitResult" :loading="submitting">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { analysisApi } from '../api/index'
import { DataAnalysis, Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const submitting = ref(false)
const results = ref<any[]>([])
const searchPeriod = ref('')
const showCreateDialog = ref(false)
const currentPeriod = computed(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const form = ref({
period: currentPeriod.value,
conclusion: '',
data_source: '',
calculation_logic: '',
comparable_benchmark: '',
limitations: '',
kpi_code: '',
kpi_name: '',
has_actual: false,
has_target: false,
has_trend: false,
has_review: false,
})
// 置信度评分标准
const confidenceLevels = [
{ level: '95%', type: 'success' as const, desc: '有全部数据 + 人工复核 — 结论极为可靠' },
{ level: '85%', type: 'success' as const, desc: '有实际值 + 目标值 + 历史趋势 — 结论高度可信' },
{ level: '70%', type: 'warning' as const, desc: '有实际值 + 目标值 — 结论较为可信' },
{ level: '50%', type: 'warning' as const, desc: '有实际值 — 结论基本可信' },
{ level: '30%', type: 'danger' as const, desc: '无实际值,基于推测 — 结论仅供参考' },
]
// 预览置信度
const previewConfidence = computed(() => {
const { has_actual, has_target, has_trend, has_review } = form.value
if (has_review) return 95
if (has_actual && has_target && has_trend) return 85
if (has_actual && has_target) return 70
if (has_actual) return 50
return 30
})
// 统计数据
const totalResults = computed(() => results.value.length)
const highConfidenceCount = computed(() => results.value.filter(r => parseInt(r.置信度) >= 85).length)
const midConfidenceCount = computed(() => {
const v = parseInt(r.置信度)
return results.value.filter(r => {
const c = parseInt(r.置信度)
return c >= 50 && c < 85
}).length
})
const lowConfidenceCount = computed(() => results.value.filter(r => parseInt(r.置信度) < 50).length)
function confidenceColor(score: number): string {
if (score >= 85) return '#67c23a'
if (score >= 50) return '#e6a23c'
return '#f56c6c'
}
function dataFlags(r: any) {
return [
{ label: '实际值', active: r.has_actual },
{ label: '目标值', active: r.has_target },
{ label: '趋势', active: r.has_trend },
{ label: '复核', active: r.has_review },
]
}
async function loadResults() {
loading.value = true
try {
const params: any = {}
if (searchPeriod.value) params.period = searchPeriod.value
const res: any = await analysisApi.getResults(params)
results.value = res.results || []
} catch (e: any) {
console.error('加载分析结论失败', e)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function submitResult() {
if (!form.value.period || !form.value.conclusion) {
ElMessage.warning('请填写期间和分析结论')
return
}
submitting.value = true
try {
const res: any = await analysisApi.createResult({
period: form.value.period,
conclusion: form.value.conclusion,
data_source: form.value.data_source || undefined,
calculation_logic: form.value.calculation_logic || undefined,
comparable_benchmark: form.value.comparable_benchmark || undefined,
limitations: form.value.limitations || undefined,
kpi_code: form.value.kpi_code || undefined,
kpi_name: form.value.kpi_name || undefined,
has_actual: form.value.has_actual,
has_target: form.value.has_target,
has_trend: form.value.has_trend,
has_review: form.value.has_review,
})
ElMessage.success(`✅ 分析结论已保存,置信度 ${res.置信度}`)
showCreateDialog.value = false
resetForm()
loadResults()
} catch (e: any) {
console.error('提交失败', e)
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
}
function confirmDelete(r: any) {
ElMessageBox.confirm(`确定删除「${r.结论?.substring(0, 30)}...」这条结论?`, '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
try {
await analysisApi.deleteResult(r.id)
ElMessage.success('已删除')
loadResults()
} catch {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
function resetForm() {
form.value = {
period: currentPeriod.value,
conclusion: '',
data_source: '',
calculation_logic: '',
comparable_benchmark: '',
limitations: '',
kpi_code: '',
kpi_name: '',
has_actual: false,
has_target: false,
has_trend: false,
has_review: false,
}
}
onMounted(loadResults)
</script>
<style scoped>
.confidence-page { padding: 0; max-width: 1200px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left h3 { margin: 0; font-size: 20px; color: #303133; }
.header-right { display: flex; align-items: center; }
.stats-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; }
.stat-card {
background: #fff; border-radius: 10px; padding: 20px; text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-num { font-size: 32px; font-weight: bold; color: #409EFF; line-height: 1.2; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.legend-card { margin-bottom: 20px; }
.legend-card :deep(.el-card__body) { padding: 16px 20px; }
.legend-title { font-size: 14px; font-weight: 600; margin-bottom: 10px; color: #303133; }
.legend-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-desc { font-size: 12px; color: #606266; }
.results-list { display: flex; flex-direction: column; gap: 14px; }
.result-card { border-radius: 10px; }
.result-card :deep(.el-card__body) { padding: 20px; }
.result-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.result-meta { display: flex; align-items: center; gap: 8px; }
.kpi-tag { font-size: 12px; color: #409EFF; background: #ecf5ff; padding: 2px 8px; border-radius: 4px; }
.result-actions { display: flex; gap: 4px; }
.conclusion-text { font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 14px; line-height: 1.6; }
.confidence-bar-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.confidence-label { font-size: 13px; color: #909399; white-space: nowrap; width: 60px; }
.confidence-bar-wrap :deep(.el-progress) { flex: 1; }
.confidence-bar-wrap :deep(.el-progress-bar__innerText) { font-size: 12px; font-weight: 600; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.detail-item { display: flex; flex-direction: column; gap: 2px; }
.detail-label { font-size: 11px; color: #909399; }
.detail-val { font-size: 13px; color: #303133; word-break: break-all; }
.detail-val.limitation { color: #e6a23c; }
.data-flags { display: flex; gap: 8px; flex-wrap: wrap; border-top: 1px solid #f0f0f0; padding-top: 12px; }
.calc-preview { margin-top: 16px; padding: 12px 16px; background: #f5f7fa; border-radius: 8px; font-size: 13px; color: #606266; }
.preview-tag { margin-left: 8px; font-size: 14px; font-weight: 600; }
@media (max-width: 768px) {
.stats-cards { grid-template-columns: repeat(2, 1fr); }
.detail-grid { grid-template-columns: 1fr; }
}
</style>