fix: 战略地图P0修复 — KPI code统一+放开同层连线+数据迁移
This commit is contained in:
Vendored
+3
@@ -18,6 +18,7 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
@@ -55,6 +56,8 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预警中心</h3>
|
||||
<div style="margin:12px 0;display:flex;gap:12px;">
|
||||
<div class="page-header">
|
||||
<h3>预警中心</h3>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="batchCheckAlerts">🔄 执行预警检查</el-button>
|
||||
<el-button size="small" @click="generateRules" :loading="generating">⚙️ 生成默认规则</el-button>
|
||||
<el-button size="small" @click="calcDynamic" :loading="calcLoading">📊 计算动态阈值</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部选项卡 -->
|
||||
<div style="margin:12px 0;display:flex;gap:12px;align-items:center;">
|
||||
<el-radio-group v-model="activeTab" size="small">
|
||||
<el-radio-button value="alerts">待处理预警</el-radio-button>
|
||||
<el-radio-button value="alerts">预警列表</el-radio-button>
|
||||
<el-radio-button value="rules">预警规则配置</el-radio-button>
|
||||
<el-radio-button value="plans">改善行动</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span v-if="totalRules > 0" style="color:#909399;font-size:12px;">共 {{ totalRules }} 条规则</span>
|
||||
</div>
|
||||
|
||||
<!-- ===== 预警列表 ===== -->
|
||||
<template v-if="activeTab === 'alerts'">
|
||||
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe>
|
||||
<el-table-column prop="alert_message" label="预警信息" min-width="300" />
|
||||
<!-- 预警类型/状态过滤 -->
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<el-select v-model="alertTypeFilter" placeholder="预警类型" clearable size="small" style="width:120px" @change="loadAlerts">
|
||||
<el-option label="静态阈值" value="static" />
|
||||
<el-option label="动态趋势" value="trend" />
|
||||
</el-select>
|
||||
<el-select v-model="alertStatusFilter" placeholder="状态" clearable size="small" style="width:100px" @change="loadAlerts">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已处理" value="resolved" />
|
||||
</el-select>
|
||||
<el-tag v-if="alertTypeFilter" closable @close="alertTypeFilter='';loadAlerts()">
|
||||
{{ alertTypeFilter === 'static' ? '静态阈值' : '动态趋势' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe size="small">
|
||||
<el-table-column prop="alert_message" label="预警信息" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<el-tag v-if="isTrendAlert(row.alert_message)" size="small" type="info" effect="plain">趋势</el-tag>
|
||||
<el-tag v-else size="small" type="primary" effect="plain">静态</el-tag>
|
||||
<span>{{ row.alert_message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_level" label="级别" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alert_level==='red'?'danger':'warning'" size="small">
|
||||
@@ -22,6 +57,9 @@
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">{{ {pending:'待处理',processing:'处理中',resolved:'已处理'}[row.status] || row.status }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='pending'" size="small" type="primary" @click="openResolveDialog(row)">处理</el-button>
|
||||
@@ -31,12 +69,62 @@
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- ===== 预警规则配置 ===== -->
|
||||
<template v-if="activeTab === 'rules'">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;">
|
||||
<el-input v-model="ruleSearch" placeholder="搜索KPI名称/编码" size="small" style="width:200px" clearable />
|
||||
<el-select v-model="ruleTypeFilter" placeholder="规则类型" clearable size="small" style="width:130px">
|
||||
<el-option label="静态阈值" value="static" />
|
||||
<el-option label="动态阈值" value="dynamic" />
|
||||
<el-option label="上升趋势" value="trend_up" />
|
||||
<el-option label="下降趋势" value="trend_down" />
|
||||
</el-select>
|
||||
<el-select v-model="ruleEnabledFilter" placeholder="状态" clearable size="small" style="width:100px">
|
||||
<el-option label="已启用" :value="1" />
|
||||
<el-option label="已禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRules">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rules" v-loading="rulesLoading" style="width:100%" border stripe size="small">
|
||||
<el-table-column label="KPI" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.kpi_name || '?' }}</strong>
|
||||
<span style="color:#909399;margin-left:4px;font-size:12px;">{{ row.kpi_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rule_type" label="规则类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="ruleTypeTag(row.rule_type)" size="small">
|
||||
{{ ruleTypeLabel(row.rule_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:#606266;">{{ formatRuleParams(row.params, row.rule_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" size="small" @change="(v) => toggleRule(row.id, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text @click="editRule(row)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- ===== 改善行动计划 ===== -->
|
||||
<template v-if="activeTab === 'plans'">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" size="small" @click="showPlanForm = true; planForm = { title: '', kpi_id: null, assignee: '', priority: 'medium', description: '' }">新建改善计划</el-button>
|
||||
</div>
|
||||
<el-table :data="plans" v-loading="loadingPlans" style="width:100%" border stripe>
|
||||
<el-table :data="plans" v-loading="loadingPlans" style="width:100%" border stripe size="small">
|
||||
<el-table-column prop="title" label="计划名称" min-width="200" />
|
||||
<el-table-column prop="kpi_name" label="关联KPI" width="140" />
|
||||
<el-table-column prop="assignee" label="负责人" width="100" />
|
||||
@@ -71,6 +159,11 @@
|
||||
<!-- 处理预警弹窗 -->
|
||||
<el-dialog v-model="showDialog" title="处理预警" width="500px">
|
||||
<el-form :model="resolveForm" label-width="100px">
|
||||
<el-form-item label="预警类型">
|
||||
<el-tag :type="isTrendAlert(currentAlert?.alert_message) ? 'info' : 'primary'" size="small">
|
||||
{{ isTrendAlert(currentAlert?.alert_message) ? '动态趋势' : '静态阈值' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理人">
|
||||
<el-select v-model="resolveForm.assignee" filterable style="width:100%;">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.name" />
|
||||
@@ -97,6 +190,44 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑预警规则弹窗 -->
|
||||
<el-dialog v-model="showRuleForm" title="编辑预警规则" width="500px">
|
||||
<el-form :model="ruleForm" label-width="110px" size="small">
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="ruleForm.rule_type" style="width:100%">
|
||||
<el-option label="静态阈值" value="static" />
|
||||
<el-option label="动态阈值" value="dynamic" />
|
||||
<el-option label="上升趋势预警" value="trend_up" />
|
||||
<el-option label="下降趋势预警" value="trend_down" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="ruleForm.enabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<template v-if="ruleForm.rule_type === 'static'">
|
||||
<el-form-item label="绿灯阈值"><el-input v-model="ruleForm.params.green" placeholder="如 >=90" /></el-form-item>
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="ruleForm.params.yellow" placeholder="如 >=80" /></el-form-item>
|
||||
<el-form-item label="红灯阈值"><el-input v-model="ruleForm.params.red" placeholder="如 <80" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="ruleForm.rule_type === 'dynamic'">
|
||||
<el-form-item label="敏感度">
|
||||
<el-input-number v-model="ruleForm.params.sensitivity" :min="0.5" :max="3" :step="0.1" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">值越大越不敏感</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="ruleForm.rule_type === 'trend_up' || ruleForm.rule_type === 'trend_down'">
|
||||
<el-form-item label="变化阈值(%)">
|
||||
<el-input-number v-model="ruleForm.params.threshold_pct" :min="1" :max="100" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">超过此百分比触发预警</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRuleForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRule" :loading="savingRule">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建/编辑改善计划弹窗 -->
|
||||
<el-dialog v-model="showPlanForm" :title="editingPlan ? '编辑改善计划' : '新建改善计划'" width="550px">
|
||||
<el-form :model="planForm" label-width="100px">
|
||||
@@ -137,24 +268,45 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { alertApi, userApi, actionPlanApi, kpiApi } from '../api/index'
|
||||
import { alertApi, userApi, actionPlanApi, kpiApi, alertRulesApi } from '../api/index'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref((route.meta?.tab as string) || 'alerts')
|
||||
|
||||
// 预警
|
||||
// ── 预警 ──
|
||||
const alerts = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const currentAlert = ref<any>(null)
|
||||
const resolving = ref(false)
|
||||
const alertTypeFilter = ref('')
|
||||
const alertStatusFilter = ref('')
|
||||
const resolveForm = ref({
|
||||
assignee: '', resolution: '',
|
||||
createPlan: false, planTitle: '', planDueDate: null,
|
||||
})
|
||||
const users = ref<any[]>([])
|
||||
|
||||
// 改善计划
|
||||
// ── 预警规则 ──
|
||||
const rules = ref<any[]>([])
|
||||
const rulesLoading = ref(false)
|
||||
const totalRules = ref(0)
|
||||
const ruleSearch = ref('')
|
||||
const ruleTypeFilter = ref('')
|
||||
const ruleEnabledFilter = ref<number | null>(null)
|
||||
const showRuleForm = ref(false)
|
||||
const savingRule = ref(false)
|
||||
const editingRuleId = ref<number | null>(null)
|
||||
const generating = ref(false)
|
||||
const calcLoading = ref(false)
|
||||
const ruleForm = ref({
|
||||
kpi_id: null as number | null,
|
||||
rule_type: 'static',
|
||||
enabled: 1,
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
|
||||
})
|
||||
|
||||
// ── 改善计划 ──
|
||||
const plans = ref<any[]>([])
|
||||
const loadingPlans = ref(false)
|
||||
const showPlanForm = ref(false)
|
||||
@@ -166,6 +318,33 @@ const planForm = ref<any>({
|
||||
description: '', due_date: null,
|
||||
})
|
||||
|
||||
// ── 工具函数 ──
|
||||
function isTrendAlert(msg: string): boolean {
|
||||
return msg?.startsWith('[趋势') || msg?.startsWith('[动态')
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return '-'
|
||||
try { return new Date(t).toLocaleString('zh-CN', { hour12: false }) } catch { return t }
|
||||
}
|
||||
|
||||
function ruleTypeLabel(type: string): string {
|
||||
return { static: '静态阈值', dynamic: '动态阈值', trend_up: '上升趋势', trend_down: '下降趋势' }[type] || type
|
||||
}
|
||||
function ruleTypeTag(type: string): string {
|
||||
return { static: 'primary', dynamic: 'success', trend_up: 'warning', trend_down: 'danger' }[type] || 'info'
|
||||
}
|
||||
|
||||
function formatRuleParams(params: any, type: string): string {
|
||||
if (!params) return '-'
|
||||
if (typeof params === 'string') { try { params = JSON.parse(params) } catch { return params } }
|
||||
if (type === 'static') return `绿${params.green||'-'} / 黄${params.yellow||'-'} / 红${params.red||'-'}`
|
||||
if (type === 'dynamic') return `敏感度=${params.sensitivity||1.0}`
|
||||
if (type === 'trend_up' || type === 'trend_down') return `阈值>${params.threshold_pct||10}%`
|
||||
return JSON.stringify(params)
|
||||
}
|
||||
|
||||
// ── 预警操作 ──
|
||||
function openResolveDialog(row: any) {
|
||||
currentAlert.value = row
|
||||
resolveForm.value = {
|
||||
@@ -177,7 +356,6 @@ function openResolveDialog(row: any) {
|
||||
}
|
||||
|
||||
function openPlanDialog(row: any) {
|
||||
// 从预警创建改善计划
|
||||
const kpiId = row.kpi_id || null
|
||||
showPlanForm.value = true
|
||||
editingPlan.value = null
|
||||
@@ -199,8 +377,6 @@ async function doResolve() {
|
||||
assignee: resolveForm.value.assignee,
|
||||
resolution: resolveForm.value.resolution,
|
||||
})
|
||||
|
||||
// 如果勾选了创建改善计划
|
||||
if (resolveForm.value.createPlan && resolveForm.value.planTitle) {
|
||||
await actionPlanApi.create({
|
||||
title: resolveForm.value.planTitle,
|
||||
@@ -212,14 +388,87 @@ async function doResolve() {
|
||||
due_date: resolveForm.value.planDueDate || null,
|
||||
})
|
||||
}
|
||||
|
||||
ElMessage.success('已处理')
|
||||
showDialog.value = false
|
||||
load()
|
||||
loadAlerts()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
resolving.value = false
|
||||
}
|
||||
|
||||
// ── 预警规则操作 ──
|
||||
function editRule(row: any) {
|
||||
editingRuleId.value = row.id
|
||||
let params = row.params
|
||||
if (typeof params === 'string') { try { params = JSON.parse(params) } catch { params = {} } }
|
||||
ruleForm.value = {
|
||||
kpi_id: row.kpi_id,
|
||||
rule_type: row.rule_type,
|
||||
enabled: row.enabled,
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, ...(params || {}) },
|
||||
}
|
||||
showRuleForm.value = true
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
savingRule.value = true
|
||||
try {
|
||||
const data = {
|
||||
rule_type: ruleForm.value.rule_type,
|
||||
enabled: ruleForm.value.enabled,
|
||||
params: ruleForm.value.params,
|
||||
}
|
||||
if (editingRuleId.value) {
|
||||
await alertRulesApi.update(editingRuleId.value, data)
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
showRuleForm.value = false
|
||||
loadRules()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
savingRule.value = false
|
||||
}
|
||||
|
||||
async function toggleRule(id: number, enabled: number) {
|
||||
try { await alertRulesApi.update(id, { enabled }) } catch { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此预警规则?')
|
||||
await alertRulesApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
loadRules()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function generateRules() {
|
||||
generating.value = true
|
||||
try {
|
||||
const r: any = await alertRulesApi.generateDefaults()
|
||||
ElMessage.success(r.message || '规则已生成')
|
||||
loadRules()
|
||||
} catch (e) { ElMessage.error('生成失败') }
|
||||
generating.value = false
|
||||
}
|
||||
|
||||
async function calcDynamic() {
|
||||
calcLoading.value = true
|
||||
try {
|
||||
const r: any = await alertRulesApi.calculateDynamic()
|
||||
ElMessage.success(r.message || '动态阈值已计算')
|
||||
} catch (e) { ElMessage.error('计算失败') }
|
||||
calcLoading.value = false
|
||||
}
|
||||
|
||||
async function batchCheckAlerts() {
|
||||
ElMessage.info('正在执行预警检查...')
|
||||
try {
|
||||
const r: any = await alertRulesApi.checkAll()
|
||||
ElMessage.success(r.message || '检查完成')
|
||||
loadAlerts()
|
||||
} catch (e) { ElMessage.error('检查失败') }
|
||||
}
|
||||
|
||||
// ── 改善计划操作 ──
|
||||
async function savePlan() {
|
||||
if (!planForm.value.title || !planForm.value.kpi_id) {
|
||||
ElMessage.warning('请填写计划名称并选择关联KPI')
|
||||
@@ -229,7 +478,6 @@ async function savePlan() {
|
||||
try {
|
||||
const data = { ...planForm.value }
|
||||
if (data.due_date) data.due_date = new Date(data.due_date).toISOString()
|
||||
|
||||
if (editingPlan.value) {
|
||||
await actionPlanApi.update(editingPlan.value.id, data)
|
||||
ElMessage.success('已更新')
|
||||
@@ -267,13 +515,48 @@ async function deletePlan(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// ── 加载数据 ──
|
||||
async function loadAlerts() {
|
||||
loading.value = true
|
||||
try { const r: any = await alertApi.list(); alerts.value = r.data || [] } catch (e) {}
|
||||
try {
|
||||
const params: any = {}
|
||||
if (alertStatusFilter.value) params.status = alertStatusFilter.value
|
||||
const r: any = await alertApi.list(params)
|
||||
let items = r.data || []
|
||||
// 客户端过滤预警类型
|
||||
if (alertTypeFilter.value === 'static') {
|
||||
items = items.filter((a: any) => !isTrendAlert(a.alert_message))
|
||||
} else if (alertTypeFilter.value === 'trend') {
|
||||
items = items.filter((a: any) => isTrendAlert(a.alert_message))
|
||||
}
|
||||
alerts.value = items
|
||||
} catch (e) {}
|
||||
try { const u: any = await userApi.list(); users.value = u.data || [] } catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (ruleTypeFilter.value) params.rule_type = ruleTypeFilter.value
|
||||
if (ruleEnabledFilter.value !== null) params.enabled = ruleEnabledFilter.value
|
||||
const r: any = await alertRulesApi.list(params)
|
||||
let items = r.data || []
|
||||
// 客户端搜索
|
||||
if (ruleSearch.value) {
|
||||
const kw = ruleSearch.value.toLowerCase()
|
||||
items = items.filter((i: any) =>
|
||||
(i.kpi_name || '').toLowerCase().includes(kw) ||
|
||||
(i.kpi_code || '').toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
rules.value = items
|
||||
totalRules.value = r.total || items.length
|
||||
} catch (e) { console.error(e) }
|
||||
rulesLoading.value = false
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
loadingPlans.value = true
|
||||
try { const r: any = await actionPlanApi.list(); plans.value = r.data || [] } catch (e) {}
|
||||
@@ -285,8 +568,15 @@ async function loadKpis() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadAlerts()
|
||||
loadRules()
|
||||
loadPlans()
|
||||
loadKpis()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
||||
.page-header h3 { margin:0; }
|
||||
.header-actions { display:flex; gap:8px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2 style="margin:0;">BI报表中心</h2>
|
||||
<div>
|
||||
<el-button type="primary" @click="showTemplateDialog = true">从模板创建</el-button>
|
||||
<el-button @click="showNewReport = true">新建分析报表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预置模板 -->
|
||||
<el-card shadow="never" style="margin-bottom:16px;">
|
||||
<template #header>预置报表模板</template>
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="tpl in templates" :key="tpl.id" :span="24 / templates.length || 1">
|
||||
<el-card shadow="hover" :body-style="{ padding: '16px', cursor: 'pointer' }" @click="useTemplate(tpl)">
|
||||
<div style="text-align:center;">
|
||||
<div style="font-size:32px;margin-bottom:8px;">
|
||||
<span v-if="tpl.report_type === 'overview'">📊</span>
|
||||
<span v-else-if="tpl.report_type === 'trend'">📈</span>
|
||||
<span v-else-if="tpl.report_type === 'comparison'">📋</span>
|
||||
<span v-else-if="tpl.report_type === 'topn'">🔝</span>
|
||||
<span v-else>🔗</span>
|
||||
</div>
|
||||
<div style="font-weight:600;">{{ tpl.name }}</div>
|
||||
<div style="font-size:12px;color:#999;margin-top:4px;">{{ tpl.config?.description || '' }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 分析引擎 -->
|
||||
<el-card v-if="analysisResult" shadow="never" style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>{{ currentReportName }}</span>
|
||||
<div>
|
||||
<el-button size="small" @click="showSaveDialog = true">保存报表</el-button>
|
||||
<el-button size="small" @click="exportData">导出CSV</el-button>
|
||||
<el-button size="small" @click="analysisResult = null">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<v-chart :option="analysisChartOption" autoresize style="height:350px;width:100%;" />
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="analysisResult.rows" size="small" border style="width:100%;margin-top:12px;" max-height="400">
|
||||
<el-table-column prop="kpi_code" label="编码" width="100" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
|
||||
<el-table-column prop="dimension" label="维度" width="80">
|
||||
<template #default="{ row }">{{ { finance: '财务', customer: '客户', process: '流程', learning: '学习' }[row.dimension] || row.dimension }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_value" label="当前值" width="100" />
|
||||
<el-table-column prop="current_period" label="期间" width="100" />
|
||||
<el-table-column prop="target_value" label="目标值" width="100" />
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 已保存报表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>已保存报表</template>
|
||||
<el-table :data="reports" size="small" v-loading="loading">
|
||||
<el-table-column prop="name" label="报表名称" min-width="200" />
|
||||
<el-table-column prop="chart_type" label="图表类型" width="100" />
|
||||
<el-table-column prop="is_shared" label="分享状态" width="100">
|
||||
<template #default="{ row }">{{ row.is_shared ? '已分享' : '私有' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openReport(row)">打开</el-button>
|
||||
<el-button size="small" type="danger" plain @click="deleteReport(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 模板选择对话框 -->
|
||||
<el-dialog v-model="showTemplateDialog" title="选择报表模板" :width="400">
|
||||
<el-table :data="templates" highlight-current-row @row-click="tpl => { selectedTemplate = tpl; }" :show-header="false">
|
||||
<el-table-column prop="name" label="模板" min-width="120" />
|
||||
<el-table-column prop="report_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.report_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="showTemplateDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmTemplate" :disabled="!selectedTemplate">使用此模板</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建分析报表 -->
|
||||
<el-dialog v-model="showNewReport" title="新建分析报表" :width="600">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="报表名称">
|
||||
<el-input v-model="newReportName" placeholder="输入报表名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="维度筛选">
|
||||
<el-checkbox-group v-model="selectedDims">
|
||||
<el-checkbox label="finance">财务</el-checkbox>
|
||||
<el-checkbox label="customer">客户</el-checkbox>
|
||||
<el-checkbox label="process">流程</el-checkbox>
|
||||
<el-checkbox label="learning">学习</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="图表类型">
|
||||
<el-select v-model="chartType" style="width:100%">
|
||||
<el-option label="自动" value="auto" />
|
||||
<el-option label="柱状图" value="bar" />
|
||||
<el-option label="折线图" value="line" />
|
||||
<el-option label="饼图" value="pie" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showNewReport = false">取消</el-button>
|
||||
<el-button type="primary" @click="doAnalyze" :loading="analyzing">生成报表</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 保存报表对话框 -->
|
||||
<el-dialog v-model="showSaveDialog" title="保存报表" :width="400">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="报表名称">
|
||||
<el-input v-model="saveReportName" placeholder="输入报表名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否分享">
|
||||
<el-switch v-model="saveShare" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showSaveDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveReport" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</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 { biReportApi, kpiApi } from '../api/index'
|
||||
|
||||
const templates = ref<any[]>([])
|
||||
const reports = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const analyzing = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 模板对话框
|
||||
const showTemplateDialog = ref(false)
|
||||
const selectedTemplate = ref<any>(null)
|
||||
|
||||
// 新建报表
|
||||
const showNewReport = ref(false)
|
||||
const newReportName = ref('')
|
||||
const selectedDims = ref(['finance', 'customer', 'process', 'learning'])
|
||||
const chartType = ref('auto')
|
||||
|
||||
// 分析结果
|
||||
const analysisResult = ref<any>(null)
|
||||
const currentReportName = ref('')
|
||||
|
||||
// 保存
|
||||
const showSaveDialog = ref(false)
|
||||
const saveReportName = ref('')
|
||||
const saveShare = ref(false)
|
||||
const currentConfig = ref<any>(null)
|
||||
|
||||
const analysisChartOption = computed(() => {
|
||||
if (!analysisResult.value) return {}
|
||||
const rows = analysisResult.value.rows || []
|
||||
const chartTypeVal = analysisResult.value.chart_type || 'auto'
|
||||
|
||||
if (chartTypeVal === 'bar' || chartTypeVal === 'auto') {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: rows.map(r => r.kpi_code), axisLabel: { rotate: 45, fontSize: 10 } },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '当前值', type: 'bar', data: rows.map(r => r.current_value), itemStyle: { color: '#409eff' } },
|
||||
{ name: '目标值', type: 'bar', data: rows.map(r => r.target_value), itemStyle: { color: '#e6a23c' } },
|
||||
],
|
||||
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
|
||||
}
|
||||
}
|
||||
if (chartTypeVal === 'line') {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: rows.map(r => r.kpi_code) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{
|
||||
type: 'line', data: rows.map(r => r.current_value),
|
||||
smooth: true, lineStyle: { width: 2 },
|
||||
areaStyle: { color: 'rgba(64,158,255,0.1)' },
|
||||
}],
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [tr, rr]: any = await Promise.all([
|
||||
biReportApi.templates(),
|
||||
biReportApi.list(),
|
||||
])
|
||||
templates.value = tr.data || []
|
||||
reports.value = rr.data || []
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function useTemplate(tpl: any) {
|
||||
showTemplateDialog.value = false
|
||||
currentReportName.value = tpl.name
|
||||
selectedDims.value = tpl.config?.dimensions || ['finance', 'customer', 'process', 'learning']
|
||||
chartType.value = tpl.config?.chart_type || 'auto'
|
||||
|
||||
const config: any = {
|
||||
dimensions: selectedDims.value,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
}
|
||||
if (tpl.config?.window_months) {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - tpl.config.window_months)
|
||||
config.period_start = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
currentConfig.value = config
|
||||
|
||||
doAnalyze()
|
||||
}
|
||||
|
||||
function confirmTemplate() {
|
||||
if (selectedTemplate.value) {
|
||||
useTemplate(selectedTemplate.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function doAnalyze() {
|
||||
analyzing.value = true
|
||||
try {
|
||||
const config: any = {
|
||||
dimensions: selectedDims.value,
|
||||
chart_type: chartType.value,
|
||||
}
|
||||
if (currentConfig.value?.period_start) config.period_start = currentConfig.value.period_start
|
||||
const r: any = await biReportApi.analyze({ config, chart_type: chartType.value })
|
||||
analysisResult.value = r
|
||||
showNewReport.value = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '分析失败')
|
||||
}
|
||||
analyzing.value = false
|
||||
}
|
||||
|
||||
async function saveReport() {
|
||||
saving.value = true
|
||||
try {
|
||||
await biReportApi.create({
|
||||
name: saveReportName.value || currentReportName.value || '未命名报表',
|
||||
config: currentConfig.value || analysisResult.value?.config || {},
|
||||
chart_type: chartType.value,
|
||||
is_shared: saveShare.value ? 1 : 0,
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
showSaveDialog.value = false
|
||||
await loadData()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function openReport(report: any) {
|
||||
currentReportName.value = report.name
|
||||
chartType.value = report.chart_type || 'auto'
|
||||
currentConfig.value = report.config || {}
|
||||
await doAnalyze()
|
||||
}
|
||||
|
||||
async function deleteReport(id: number) {
|
||||
try {
|
||||
await biReportApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadData()
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
if (!analysisResult.value) return
|
||||
const rows = analysisResult.value.rows || []
|
||||
const header = 'KPI编码,KPI名称,维度,当前值,期间,目标值,单位\n'
|
||||
const csv = header + rows.map((r: any) =>
|
||||
`${r.kpi_code},${r.kpi_name},${r.dimension},${r.current_value ?? ''},${r.current_period ?? ''},${r.target_value ?? ''},${r.unit ?? ''}`
|
||||
).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `报表_${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => { loadData() })
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2 style="margin:0;">数据质量监控</h2>
|
||||
<div>
|
||||
<el-button type="primary" @click="runCheck" :loading="checking">执行全量检查</el-button>
|
||||
<el-button @click="loadLogs">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never">
|
||||
<div style="text-align:center;">
|
||||
<div style="font-size:28px;font-weight:600;color:#409eff;">{{ stats.total_kpis }}</div>
|
||||
<div style="font-size:13px;color:#999;">KPI总数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never">
|
||||
<div style="text-align:center;">
|
||||
<div style="font-size:28px;font-weight:600;color:#e6a23c;">{{ stats.open_logs }}</div>
|
||||
<div style="font-size:13px;color:#999;">未解决异常</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never">
|
||||
<div style="text-align:center;">
|
||||
<div style="font-size:28px;font-weight:600;color:#f56c6c;">{{ stats.severity_counts?.critical ?? 0 }}</div>
|
||||
<div style="font-size:13px;color:#999;">严重异常</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never">
|
||||
<div style="text-align:center;">
|
||||
<div style="font-size:28px;font-weight:600;color:#67c23a;">{{ stats.type_counts?.missing_data ?? 0 }}</div>
|
||||
<div style="font-size:13px;color:#999;">数据缺失</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 异常类型分布 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<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-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: '#f56c6c' } },
|
||||
{ name: '环比骤变', value: stats.value.type_counts?.abnormal_change || 0, itemStyle: { color: '#e6a23c' } },
|
||||
{ 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}' },
|
||||
}],
|
||||
}))
|
||||
|
||||
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>
|
||||
@@ -8,96 +8,231 @@
|
||||
<el-tag v-if="kpi.dimension" :type="dimTagType(kpi.dimension)" size="small" style="margin-left:4px;">{{ dimLabel(kpi.dimension) }}</el-tag>
|
||||
<el-tag v-if="kpi.category" type="info" size="small" style="margin-left:4px;">{{ catLabel(kpi.category) }}</el-tag>
|
||||
</template>
|
||||
<el-form :model="kpi" label-width="120px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码">{{ kpi.kpi_code }}</el-form-item>
|
||||
<el-form-item label="KPI名称"><el-input v-model="kpi.kpi_name" /></el-form-item>
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="kpi.dimension" style="width:100%">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="二级类别">
|
||||
<el-select v-model="kpi.category" style="width:100%" :disabled="!kpi.dimension">
|
||||
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input-number v-model="kpi.target_value" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
|
||||
<el-form-item label="频率">
|
||||
<el-select v-model="kpi.frequency" style="width:100%">
|
||||
<el-option label="月度" value="monthly" />
|
||||
<el-option label="季度" value="quarterly" />
|
||||
<el-option label="年度" value="yearly" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="kpi.responsible_dept" /></el-form-item>
|
||||
<el-form-item label="负责人"><el-input v-model="kpi.responsible_user" /></el-form-item>
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="kpi.data_source_type" style="width:100%">
|
||||
<el-option label="ERP" value="erp" />
|
||||
<el-option label="Excel" value="excel" />
|
||||
<el-option label="手工" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属Epic">
|
||||
<el-select v-model="kpi.epic" style="width:100%">
|
||||
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="绿灯阈值">
|
||||
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item>
|
||||
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="计算公式"><el-input v-model="kpi.formula" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-form :model="kpi" label-width="120px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码">{{ kpi.kpi_code }}</el-form-item>
|
||||
<el-form-item label="KPI名称"><el-input v-model="kpi.kpi_name" /></el-form-item>
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="kpi.dimension" style="width:100%">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="二级类别">
|
||||
<el-select v-model="kpi.category" style="width:100%" :disabled="!kpi.dimension">
|
||||
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input-number v-model="kpi.target_value" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
|
||||
<el-form-item label="频率">
|
||||
<el-select v-model="kpi.frequency" style="width:100%">
|
||||
<el-option label="月度" value="monthly" />
|
||||
<el-option label="季度" value="quarterly" />
|
||||
<el-option label="年度" value="yearly" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="kpi.responsible_dept" /></el-form-item>
|
||||
<el-form-item label="负责人"><el-input v-model="kpi.responsible_user" /></el-form-item>
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="kpi.data_source_type" style="width:100%">
|
||||
<el-option label="ERP" value="erp" />
|
||||
<el-option label="Excel" value="excel" />
|
||||
<el-option label="手工" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属Epic">
|
||||
<el-select v-model="kpi.epic" style="width:100%">
|
||||
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联战略地图">
|
||||
<div style="display:flex;gap:8px;width:100%;">
|
||||
<el-select v-model="kpi.map_id" placeholder="选择战略地图" clearable style="flex:1;" @change="onMapChange">
|
||||
<el-option v-for="m in mapOptions" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" plain @click="associateMap">关联</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="绿灯阈值">
|
||||
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item>
|
||||
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="计算公式"><el-input v-model="kpi.formula" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="历史数据" name="history">
|
||||
<v-chart :option="chartOption" autoresize style="height:300px;width:100%;" />
|
||||
<el-table :data="values" size="small" style="width:100%;margin-top:12px;">
|
||||
<el-table-column prop="period" label="期间" width="120" />
|
||||
<el-table-column prop="actual_value" label="实际值" width="120" />
|
||||
<el-table-column prop="source_type" label="来源" width="80" />
|
||||
<el-table-column prop="data_status" label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ row.data_status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="因果链" name="causality">
|
||||
<div v-loading="networkLoading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><b>上游驱动</b>(影响本KPI的因子)</template>
|
||||
<div v-if="upstream.length === 0" style="color:#999;text-align:center;padding:20px;">无上游驱动</div>
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="item in upstream" :key="item.kpi_id"
|
||||
:color="item.direction === 'positive' ? '#67c23a' : '#f56c6c'"
|
||||
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
|
||||
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div>
|
||||
<div style="font-size:12px;color:#999;">
|
||||
{{ item.direction === 'positive' ? '正向驱动' : '负向抑制' }}
|
||||
<span v-if="item.formula"> · {{ item.formula }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><b>下游影响</b>(本KPI影响的指标)</template>
|
||||
<div v-if="downstream.length === 0" style="color:#999;text-align:center;padding:20px;">无下游影响</div>
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="item in downstream" :key="item.kpi_id"
|
||||
:color="item.direction === 'positive' ? '#67c23a' : '#f56c6c'"
|
||||
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
|
||||
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div>
|
||||
<div style="font-size:12px;color:#999;">
|
||||
{{ item.direction === 'positive' ? '正向推动' : '负向抑制' }}
|
||||
<span v-if="item.formula"> · {{ item.formula }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" @click="showSimulate = true">模拟推演</el-button>
|
||||
<el-button @click="showFullNetwork = true">查看全局因果网络图</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 因果链力导向图 -->
|
||||
<el-card v-if="networkEdges.length > 0" shadow="never">
|
||||
<template #header><b>因果网络图</b></template>
|
||||
<v-chart :option="networkChartOption" autoresize style="height:450px;width:100%;" />
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-top:16px;">
|
||||
<template #header>趋势图</template>
|
||||
<v-chart :option="chartOption" autoresize style="height:300px;width:100%;" />
|
||||
</el-card>
|
||||
<!-- 模拟推演对话框 -->
|
||||
<el-dialog v-model="showSimulate" title="因果链模拟推演" :width="700" top="5vh">
|
||||
<div v-if="simulateResult">
|
||||
<el-alert :title="'修改 ' + simulateResult.source.kpi_name + ' 从 ' + simulateResult.source.current_value + ' 到 ' + simulateResult.source.new_value + '(变化率 ' + simulateResult.source.change_pct + '%)'"
|
||||
type="info" show-icon :closable="false" style="margin-bottom:12px;" />
|
||||
<el-table :data="simulateResult.impacts" size="small" max-height="400" border>
|
||||
<el-table-column prop="kpi_name" label="受影响KPI" min-width="150" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="120" />
|
||||
<el-table-column prop="dimension" label="维度" width="80">
|
||||
<template #default="{ row }">{{ row.dimension === 'finance' ? '财务' : row.dimension === 'customer' ? '客户' : row.dimension === 'process' ? '流程' : '学习' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_value" label="当前值" width="90" />
|
||||
<el-table-column prop="predicted_value" label="预测值" width="90">
|
||||
<template #default="{ row }"><span style="color:#409eff;font-weight:600;">{{ row.predicted_value ?? '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_pct" label="变化率" width="80">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.change_pct > 0 ? '#67c23a' : '#f56c6c' }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path_strength" label="传递强度" width="80" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="选择KPI">
|
||||
<el-select v-model="simulateKpiId" filterable style="width:100%" placeholder="选择要修改的KPI">
|
||||
<el-option v-for="k in allKpis" :key="k.id" :label="k.kpi_name + ' (' + k.kpi_code + ')'" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前值">{{ simulateCurrentValue ?? '无数据' }}</el-form-item>
|
||||
<el-form-item label="修改为">
|
||||
<el-input-number v-model="simulateNewValue" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showSimulate = false">关闭</el-button>
|
||||
<el-button v-if="!simulateResult" type="primary" @click="doSimulate" :loading="simulating">执行推演</el-button>
|
||||
<el-button v-else @click="resetSimulate">重新推演</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-card style="margin-top:16px;">
|
||||
<template #header>历史数据</template>
|
||||
<el-table :data="values" size="small" style="width:100%">
|
||||
<el-table-column prop="period" label="期间" width="120" />
|
||||
<el-table-column prop="actual_value" label="实际值" width="120" />
|
||||
<el-table-column prop="source_type" label="来源" width="80" />
|
||||
<el-table-column prop="data_status" label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ row.data_status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<!-- 全局因果网络图对话框 -->
|
||||
<el-dialog v-model="showFullNetwork" title="全局因果网络图" :width="900" top="5vh">
|
||||
<div v-loading="fullNetworkLoading" style="height:550px;">
|
||||
<v-chart v-if="fullNetworkNodes.length > 0" :option="fullNetworkChartOption" autoresize style="height:550px;width:100%;" />
|
||||
<div v-else style="text-align:center;padding:40px;color:#999;">加载中...</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showFullNetwork = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import VChart from 'vue-echarts'
|
||||
import 'echarts'
|
||||
import { kpiApi } from '../api/index'
|
||||
import { kpiApi, mapApi, kpiCausalityApi } from '../api/index'
|
||||
import api from '../api/index'
|
||||
|
||||
const route = useRoute()
|
||||
const kpi = ref<any>(null)
|
||||
const values = ref<any[]>([])
|
||||
const mapOptions = ref<any[]>([])
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 因果链
|
||||
const upstream = ref<any[]>([])
|
||||
const downstream = ref<any[]>([])
|
||||
const networkEdges = ref<any[]>([])
|
||||
const networkLoading = ref(false)
|
||||
const showSimulate = ref(false)
|
||||
const showFullNetwork = ref(false)
|
||||
const simulateKpiId = ref<number | null>(null)
|
||||
const simulateNewValue = ref(0)
|
||||
const simulateCurrentValue = ref<number | null>(null)
|
||||
const simulateResult = ref<any>(null)
|
||||
const simulating = ref(false)
|
||||
const allKpis = ref<any[]>([])
|
||||
const fullNetworkNodes = ref<any[]>([])
|
||||
const fullNetworkEdges = ref<any[]>([])
|
||||
const fullNetworkLoading = ref(false)
|
||||
|
||||
const CAT_MAP: Record<string, string> = {
|
||||
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
|
||||
@@ -133,6 +268,148 @@ const chartOption = computed(() => ({
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
}))
|
||||
|
||||
// 因果网络ECharts力导向图
|
||||
const networkChartOption = computed(() => {
|
||||
const nodeMap: Record<number, any> = {}
|
||||
const nodes: any[] = []
|
||||
const edges: any[] = []
|
||||
|
||||
// 中心节点
|
||||
if (kpi.value) {
|
||||
nodeMap[kpi.value.id] = {
|
||||
id: String(kpi.value.id),
|
||||
name: kpi.value.kpi_code,
|
||||
symbolSize: 50,
|
||||
itemStyle: { color: '#409eff' },
|
||||
category: 0,
|
||||
label: { fontSize: 12, fontWeight: 'bold' },
|
||||
}
|
||||
nodes.push(nodeMap[kpi.value.id])
|
||||
}
|
||||
|
||||
// 上游节点
|
||||
for (const item of upstream.value) {
|
||||
if (!nodeMap[item.kpi_id]) {
|
||||
nodeMap[item.kpi_id] = {
|
||||
id: String(item.kpi_id),
|
||||
name: item.kpi_code,
|
||||
symbolSize: 35,
|
||||
itemStyle: { color: '#67c23a' },
|
||||
category: 1,
|
||||
}
|
||||
nodes.push(nodeMap[item.kpi_id])
|
||||
}
|
||||
edges.push({
|
||||
source: String(item.kpi_id),
|
||||
target: String(kpi.value?.id),
|
||||
value: item.strength,
|
||||
lineStyle: { color: item.direction === 'positive' ? '#67c23a' : '#f56c6c', width: item.strength * 5 },
|
||||
label: { show: true, formatter: item.strength, fontSize: 10 },
|
||||
})
|
||||
}
|
||||
|
||||
// 下游节点
|
||||
for (const item of downstream.value) {
|
||||
if (!nodeMap[item.kpi_id]) {
|
||||
nodeMap[item.kpi_id] = {
|
||||
id: String(item.kpi_id),
|
||||
name: item.kpi_code,
|
||||
symbolSize: 35,
|
||||
itemStyle: { color: '#e6a23c' },
|
||||
category: 2,
|
||||
}
|
||||
nodes.push(nodeMap[item.kpi_id])
|
||||
}
|
||||
edges.push({
|
||||
source: String(kpi.value?.id),
|
||||
target: String(item.kpi_id),
|
||||
value: item.strength,
|
||||
lineStyle: { color: item.direction === 'positive' ? '#67c23a' : '#f56c6c', width: item.strength * 5 },
|
||||
label: { show: true, formatter: item.strength, fontSize: 10 },
|
||||
})
|
||||
}
|
||||
|
||||
for (const e of networkEdges.value) {
|
||||
const srcStr = String(e.source_kpi_id)
|
||||
const tgtStr = String(e.target_kpi_id)
|
||||
const exists = edges.some((ex: any) => ex.source === srcStr && ex.target === tgtStr)
|
||||
if (!exists && nodeMap[e.source_kpi_id] && nodeMap[e.target_kpi_id]) {
|
||||
edges.push({
|
||||
source: srcStr,
|
||||
target: tgtStr,
|
||||
lineStyle: { color: '#ddd', width: 1, type: 'dashed' as const },
|
||||
label: { show: false },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item' as const, formatter: (p: any) => `${p.name}<br/>强度: ${p.value ?? '-'}` },
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
force: { repulsion: 500, edgeLength: [100, 300], gravity: 0.1 },
|
||||
roam: true,
|
||||
draggable: true,
|
||||
data: nodes,
|
||||
edges: edges,
|
||||
categories: [
|
||||
{ name: '当前KPI', itemStyle: { color: '#409eff' } },
|
||||
{ name: '上游驱动', itemStyle: { color: '#67c23a' } },
|
||||
{ name: '下游影响', itemStyle: { color: '#e6a23c' } },
|
||||
],
|
||||
label: { show: true, position: 'right', fontSize: 10 },
|
||||
edgeLabel: { show: true, fontSize: 9, color: '#666' },
|
||||
lineStyle: { curveness: 0.2 },
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// 全局因果网络力导向图
|
||||
const fullNetworkChartOption = computed(() => {
|
||||
const dimColors: Record<string, string> = {
|
||||
finance: '#409eff', customer: '#67c23a', process: '#e6a23c', learning: '#9b59b6',
|
||||
}
|
||||
const nodes = fullNetworkNodes.value.map(n => ({
|
||||
id: String(n.id),
|
||||
name: n.kpi_code,
|
||||
symbolSize: 25,
|
||||
itemStyle: { color: dimColors[n.dimension] || '#999' },
|
||||
category: n.dimension === 'finance' ? 0 : n.dimension === 'customer' ? 1 : n.dimension === 'process' ? 2 : 3,
|
||||
label: { fontSize: 9 },
|
||||
}))
|
||||
const edges = fullNetworkEdges.value.map(e => ({
|
||||
source: String(e.source),
|
||||
target: String(e.target),
|
||||
lineStyle: {
|
||||
color: e.direction === 'positive' ? 'rgba(103,194,58,0.5)' : 'rgba(245,108,108,0.5)',
|
||||
width: (e.strength || 0.3) * 4,
|
||||
},
|
||||
value: e.strength,
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item' as const, formatter: (p: any) => `${p.name}<br/>${p.value ? '强度:' + p.value : ''}` },
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
force: { repulsion: 400, edgeLength: [80, 200], gravity: 0.05, friction: 0.1 },
|
||||
roam: true,
|
||||
draggable: true,
|
||||
data: nodes,
|
||||
edges: edges,
|
||||
categories: [
|
||||
{ name: '财务', itemStyle: { color: '#409eff' } },
|
||||
{ name: '客户', itemStyle: { color: '#67c23a' } },
|
||||
{ name: '流程', itemStyle: { color: '#e6a23c' } },
|
||||
{ name: '学习', itemStyle: { color: '#9b59b6' } },
|
||||
],
|
||||
label: { show: true, position: 'right', fontSize: 9 },
|
||||
lineStyle: { curveness: 0.2 },
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
try { await kpiApi.update(kpi.value.id, kpi.value); ElMessage.success('保存成功') }
|
||||
catch (e) { ElMessage.error('保存失败') }
|
||||
@@ -152,6 +429,72 @@ async function suggestThreshold() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCausality(kpiId: number) {
|
||||
networkLoading.value = true
|
||||
try {
|
||||
const [upRes, downRes, listRes]: any = await Promise.all([
|
||||
kpiCausalityApi.getNetwork(kpiId),
|
||||
kpiCausalityApi.getNetwork(kpiId),
|
||||
kpiCausalityApi.list({ source_kpi_id: kpiId }),
|
||||
])
|
||||
upstream.value = upRes.upstream || []
|
||||
downstream.value = downRes.downstream || []
|
||||
networkEdges.value = listRes.data || []
|
||||
} catch (e) {
|
||||
console.error('因果链加载失败', e)
|
||||
}
|
||||
networkLoading.value = false
|
||||
}
|
||||
|
||||
async function doSimulate() {
|
||||
if (!simulateKpiId.value) { ElMessage.warning('请选择KPI'); return }
|
||||
simulating.value = true
|
||||
try {
|
||||
const r: any = await kpiCausalityApi.simulate({
|
||||
kpi_id: simulateKpiId.value,
|
||||
new_value: simulateNewValue.value,
|
||||
})
|
||||
simulateResult.value = r
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '推演失败')
|
||||
}
|
||||
simulating.value = false
|
||||
}
|
||||
|
||||
function resetSimulate() {
|
||||
simulateResult.value = null
|
||||
}
|
||||
|
||||
watch(simulateKpiId, async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const r: any = await kpiApi.get(id)
|
||||
const kpiData = r.data || r
|
||||
simulateCurrentValue.value = kpiData.current_value ?? null
|
||||
simulateNewValue.value = kpiData.current_value ?? 0
|
||||
} catch (e) {
|
||||
simulateCurrentValue.value = null
|
||||
}
|
||||
})
|
||||
|
||||
async function loadFullNetwork() {
|
||||
fullNetworkLoading.value = true
|
||||
try {
|
||||
const r: any = await kpiCausalityApi.getFullNetwork()
|
||||
fullNetworkNodes.value = r.nodes || []
|
||||
fullNetworkEdges.value = r.edges || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载因果网络失败')
|
||||
}
|
||||
fullNetworkLoading.value = false
|
||||
}
|
||||
|
||||
watch(showFullNetwork, (val) => {
|
||||
if (val && fullNetworkNodes.value.length === 0) {
|
||||
loadFullNetwork()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await kpiApi.get(Number(route.params.id))
|
||||
kpi.value = r.data || r
|
||||
@@ -160,5 +503,31 @@ onMounted(async () => {
|
||||
const kv: any = await api.get('/kpis/' + kpi.value.id)
|
||||
values.value = kv.values || []
|
||||
} catch(e) {}
|
||||
// 加载战略地图列表
|
||||
try {
|
||||
const mr: any = await mapApi.list()
|
||||
mapOptions.value = mr.data || []
|
||||
} catch(e) {}
|
||||
// 加载因果链
|
||||
await loadCausality(Number(route.params.id))
|
||||
// 加载KPI列表(用于模拟推演选择器)
|
||||
try {
|
||||
const kr: any = await kpiApi.list({ page_size: 100 })
|
||||
allKpis.value = kr.data || []
|
||||
} catch(e) {}
|
||||
})
|
||||
|
||||
async function associateMap() {
|
||||
if (!kpi.value) return
|
||||
try {
|
||||
await kpiApi.associateMap(kpi.value.id, { map_id: kpi.value.map_id || null })
|
||||
ElMessage.success('地图关联成功')
|
||||
const r: any = await kpiApi.get(Number(route.params.id))
|
||||
kpi.value = r.data || r
|
||||
} catch (e) {
|
||||
ElMessage.error('关联失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onMapChange(val: any) {}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user