本体三支柱: 科目↔KPI↔OKR三层互联 + 追溯链API + OKR详情页签
- 新表 kpi_subject_map(89映射/24KPI) / objective_kpi(3O×5KPI) / krs(3O×3KR) - GET /api/cma/ontology/trace?objective_id=N O→KPI→科目逐层追溯 - GET /api/cma/ontology/objectives 三层链路概览 - OkrDetail.vue 新增本体追溯链页签(KR目标/当前值 + KPI→科目标签流) - 含并发已上线未提交的KPI多粒度列(target_monthly/quarterly/yearly) - 回归: pytest 409 passed
This commit is contained in:
@@ -95,6 +95,12 @@ export const okrApi = {
|
||||
api.post(`/okr/${okrId}/decomposition/milestones/generate`, { kr_id: krId }),
|
||||
}
|
||||
|
||||
// ── 本体三支柱追溯链 (科目↔KPI↔OKR) ──
|
||||
export const ontologyApi = {
|
||||
trace: (objectiveId: number) => api.get('/ontology/trace', { params: { objective_id: objectiveId } }),
|
||||
objectives: (params?: any) => api.get('/ontology/objectives', { params }),
|
||||
}
|
||||
|
||||
export const dataApi = {
|
||||
importExcel: (file: File, qs?: string) => {
|
||||
const form = new FormData()
|
||||
|
||||
@@ -66,6 +66,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="本体追溯链" name="ontology">
|
||||
<div v-if="traceLoading" class="empty-tab">追溯链加载中...</div>
|
||||
<div v-else-if="!trace" class="empty-tab">暂无追溯链数据</div>
|
||||
<template v-else>
|
||||
<div class="chain-banner">
|
||||
<span class="chain-step">O 目标</span>
|
||||
<span class="chain-arrow">→</span>
|
||||
<span class="chain-step">KPI 指标 ({{ trace.kpis.length }})</span>
|
||||
<span class="chain-arrow">→</span>
|
||||
<span class="chain-step">科目 数据 ({{ trace.chain.subject_count }})</span>
|
||||
</div>
|
||||
|
||||
<!-- KR(关键结果) -->
|
||||
<div class="trace-section" v-if="trace.krs && trace.krs.length">
|
||||
<div class="trace-section-title">关键结果 KR</div>
|
||||
<div class="kr-list">
|
||||
<div v-for="kr in trace.krs" :key="kr.id" class="kr-card">
|
||||
<div class="kr-header">
|
||||
<span class="kr-title">{{ kr.title }}</span>
|
||||
<el-tag size="small" :type="statusTagType(kr.status)">{{ statusLabel(kr.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="kr-meta">
|
||||
<span v-if="kr.metric_kpi_name" class="kr-meta-item">度量KPI: {{ kr.metric_kpi_name }}</span>
|
||||
<span class="kr-meta-item">目标: {{ kr.target_value }}</span>
|
||||
<span class="kr-meta-item">当前: {{ kr.current_value }}</span>
|
||||
<span v-if="kr.due_date" class="kr-meta-item">截止: {{ kr.due_date }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="kr.progress || 0" :stroke-width="6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI → 科目 -->
|
||||
<div class="trace-section">
|
||||
<div class="trace-section-title">KPI → 科目(指标追溯到底层账)</div>
|
||||
<div v-for="k in trace.kpis" :key="k.kpi_id" class="kpi-trace-card">
|
||||
<div class="kpi-trace-head">
|
||||
<span class="kpi-trace-code">{{ k.kpi_code }}</span>
|
||||
<span class="kpi-trace-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="dimTagType(k.dimension)">{{ dimLabel(k.dimension) }}</el-tag>
|
||||
<span v-if="k.unit" class="kpi-trace-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div v-if="k.subjects && k.subjects.length" class="subject-chips">
|
||||
<el-tag v-for="s in k.subjects" :key="s.subject_code" size="small" effect="plain" class="subject-chip">
|
||||
{{ s.subject_code }} {{ s.subject_name }}
|
||||
<span class="subject-calc">({{ s.calc_type }}, {{ s.weight }})</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="no-subject">非科目驱动指标(业务/调研数据源,不追溯账本)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="分解视图" name="decomposition">
|
||||
<OkrDecomposition :okr-id="okrId" @refresh="loadData" />
|
||||
</el-tab-pane>
|
||||
@@ -83,7 +136,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { okrApi } from '../api/index'
|
||||
import { okrApi, ontologyApi } from '../api/index'
|
||||
import OkrDecomposition from '../components/okr/OkrDecomposition.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -91,6 +144,8 @@ const okrId = computed(() => Number(route.params.id))
|
||||
|
||||
const loading = ref(true)
|
||||
const objective = ref<any>(null)
|
||||
const trace = ref<any>(null)
|
||||
const traceLoading = ref(false)
|
||||
const activeTab = ref('krs')
|
||||
|
||||
const DIM_LABELS: Record<string, string> = {
|
||||
@@ -103,11 +158,11 @@ function dimTagType(d: string) {
|
||||
return types[d] || ''
|
||||
}
|
||||
function statusLabel(s: string) {
|
||||
const labels: Record<string, string> = { active: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
const labels: Record<string, string> = { active: '进行中', completed: '已完成', cancelled: '已取消', in_progress: '进行中', pending: '待开始' }
|
||||
return labels[s] || s
|
||||
}
|
||||
function statusTagType(s: string) {
|
||||
const types: Record<string, string> = { active: 'warning', completed: 'success', cancelled: 'danger' }
|
||||
const types: Record<string, string> = { active: 'warning', completed: 'success', cancelled: 'danger', in_progress: 'warning', pending: 'info' }
|
||||
return types[s] || ''
|
||||
}
|
||||
|
||||
@@ -125,8 +180,23 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrace() {
|
||||
if (!okrId.value) return
|
||||
traceLoading.value = true
|
||||
try {
|
||||
const res: any = await ontologyApi.trace(okrId.value)
|
||||
trace.value = res
|
||||
} catch (e: any) {
|
||||
ElMessage.warning('追溯链加载失败: ' + (e?.response?.data?.detail || e?.message || ''))
|
||||
trace.value = null
|
||||
} finally {
|
||||
traceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadTrace()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -218,6 +288,84 @@ onMounted(() => {
|
||||
padding: 40px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 本体追溯链 ── */
|
||||
.chain-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(90deg, #f0f5ff, #f6ffed);
|
||||
border: 1px solid #d9e8ff;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chain-step {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.chain-arrow {
|
||||
color: #409EFF;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trace-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.trace-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #409EFF;
|
||||
}
|
||||
.kpi-trace-card {
|
||||
background: #fff;
|
||||
border: 1px solid #EBEEF5;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.kpi-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.kpi-trace-code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #409EFF;
|
||||
background: #ecf5ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.kpi-trace-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.kpi-trace-unit {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.subject-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.subject-chip {
|
||||
font-size: 12px;
|
||||
}
|
||||
.subject-calc {
|
||||
color: #909399;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.no-subject {
|
||||
font-size: 12px;
|
||||
color: #C0C4CC;
|
||||
}
|
||||
.not-found {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user