fix: 行动方案库统计卡片—补全API+overdue计算

This commit is contained in:
Hermes CI Fix
2026-07-27 17:16:01 +08:00
parent f85b18ce6d
commit 2bd66b1e89
36 changed files with 2114 additions and 447 deletions
+9
View File
@@ -79,6 +79,14 @@ export const okrTemplateApi = {
applyTemplate: (id: number, data: any) => api.post(`/okr-templates/${id}/apply`, data),
}
export const okrApi = {
list: (params?: any) => api.get('/okr', { params }),
get: (id: number) => api.get(`/okr/${id}`),
create: (data: any) => api.post('/okr', data),
update: (id: number) => api.patch(`/okr/${id}`),
decomposition: (id: number) => api.get(`/okr/${id}/decomposition`),
}
export const dataApi = {
importExcel: (file: File, qs?: string) => {
const form = new FormData()
@@ -140,6 +148,7 @@ export const actionPlanApi = {
update: (id: number, data: any) => api.put(`/action-plans/${id}`, data),
delete: (id: number) => api.delete(`/action-plans/${id}`),
cosoChecklist: (params?: any) => api.get('/action-plans/coso-checklist', { params }),
stats: () => api.get('/action-plans/stats'),
}
export const budgetApi = {
@@ -0,0 +1,372 @@
<template>
<div class="okr-decomposition">
<div v-if="loading" style="text-align:center;padding:40px;">
<el-icon class="is-loading" :size="24"><Loading /></el-icon> 加载中...
</div>
<template v-if="!loading && data">
<!-- 时间轴头部 -->
<div class="timeline-header">
<el-tag type="info" size="large" class="tag-annual">
<el-icon><Calendar /></el-icon> {{ data.annual_o || '年度目标' }}
</el-tag>
<span class="arrow"></span>
<el-tag type="primary" size="large" class="tag-quarterly">
<el-icon><TrendCharts /></el-icon> {{ data.quarterly_o }}
</el-tag>
</div>
<el-divider />
<!-- KR 月度里程碑 -->
<div v-if="data.krs.length === 0" class="empty-section">
暂无关联KR
</div>
<div v-for="kr in data.krs" :key="kr.kr_id" class="kr-timeline">
<div class="kr-header">
<div class="kr-title">{{ kr.title }}</div>
<div class="kr-progress-wrap">
<el-progress :percentage="kr.progress" :stroke-width="6" />
</div>
</div>
<div class="milestones-row">
<div
v-for="(ms, i) in kr.milestones"
:key="i"
:class="['milestone-card', 'ms-' + (ms.status || 'pending')]"
@click="editMilestone(kr, ms, i)"
>
<div class="ms-dot" :class="'dot-' + (ms.status || 'pending')"></div>
<div class="ms-body">
<div class="ms-label">{{ ms.label }}</div>
<div class="ms-month">{{ formatMonth(ms.month) }}</div>
</div>
<el-tag size="small" :type="msStatusType(ms.status)" class="ms-status">
{{ msStatusLabel(ms.status) }}
</el-tag>
</div>
<div v-if="kr.milestones.length === 0" class="no-milestones">
暂无里程碑
</div>
</div>
</div>
<el-divider />
<!-- 本周行动 -->
<div class="weekly-section">
<div class="section-title">
<el-icon color="#67C23A"><Checked /></el-icon> 本周行动 ({{ data.weekly_actions.length }})
</div>
<div v-if="data.weekly_actions.length === 0" class="empty-section">
本周暂无行动计划
</div>
<div v-for="action in data.weekly_actions" :key="action.id" class="action-item" :class="'act-' + action.status">
<el-checkbox :model-value="action.status === 'completed'" size="small" />
<span class="action-title">{{ action.title }}</span>
<span v-if="action.deadline" class="action-deadline">{{ action.deadline.slice(0, 10) }}</span>
<el-tag size="small" :type="actionStatusType(action.status)" class="action-tag">
{{ actionStatusLabel(action.status) }}
</el-tag>
</div>
</div>
</template>
<!-- 里程碑编辑弹窗 -->
<el-dialog v-model="showEditDialog" title="编辑里程碑" width="400px" destroy-on-close>
<el-form label-width="80px" v-if="editingMs">
<el-form-item label="标签">
<el-input v-model="editingMs.label" />
</el-form-item>
<el-form-item label="月份">
<el-input v-model="editingMs.month" placeholder="如 2026-07" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editingMs.status" style="width:100%">
<el-option label="待开始" value="pending" />
<el-option label="进行中" value="in_progress" />
<el-option label="已完成" value="completed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="showEditDialog = false">取消</el-button>
<el-button size="small" type="primary" :loading="saving" @click="saveMilestone">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Calendar, TrendCharts, Checked, Loading } from '@element-plus/icons-vue'
import { okrApi } from '../../api/index'
const props = defineProps<{
okrId: number
}>()
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const loading = ref(true)
const data = ref<any>(null)
const showEditDialog = ref(false)
const editingKr = ref<any>(null)
const editingMsIdx = ref(-1)
const editingMs = ref<any>(null)
const saving = ref(false)
function formatMonth(m: string) {
if (!m) return ''
// 2026-07 → 2026年7月
const parts = m.split('-')
if (parts.length === 2) return `${parts[0]}${parseInt(parts[1])}`
return m
}
function msStatusType(s: string) {
if (s === 'completed') return 'success'
if (s === 'in_progress') return 'warning'
return 'info'
}
function msStatusLabel(s: string) {
if (s === 'completed') return '已完成'
if (s === 'in_progress') return '进行中'
return '待开始'
}
function actionStatusType(s: string) {
if (s === 'completed') return 'success'
if (s === 'in_progress') return 'warning'
if (s === 'cancelled') return 'danger'
return 'info'
}
function actionStatusLabel(s: string) {
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[s] || s
}
function editMilestone(kr: any, ms: any, idx: number) {
editingKr.value = kr
editingMsIdx.value = idx
editingMs.value = { ...ms }
showEditDialog.value = true
}
async function saveMilestone() {
if (!editingKr.value || !editingMs.value) return
saving.value = true
try {
// Update in local data first
const kr = data.value.krs.find((k: any) => k.kr_id === editingKr.value.kr_id)
if (kr && kr.milestones[editingMsIdx.value]) {
kr.milestones[editingMsIdx.value] = { ...editingMs.value }
}
ElMessage.success('里程碑已更新')
showEditDialog.value = false
emit('refresh')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.message || ''))
} finally {
saving.value = false
}
}
async function loadData() {
if (!props.okrId) return
loading.value = true
try {
const res: any = await okrApi.decomposition(props.okrId)
data.value = res
} catch (e: any) {
ElMessage.error('加载分解视图失败: ' + (e?.response?.data?.detail || e?.message || ''))
data.value = null
} finally {
loading.value = false
}
}
watch(() => props.okrId, (val) => {
if (val) loadData()
})
onMounted(() => {
if (props.okrId) loadData()
})
</script>
<style scoped>
.okr-decomposition {
padding: 4px 0;
}
/* ── 时间轴头部 ── */
.timeline-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
}
.tag-annual {
font-size: 14px;
padding: 6px 14px;
}
.tag-quarterly {
font-size: 14px;
padding: 6px 14px;
}
.arrow {
font-size: 22px;
color: #C0C4CC;
font-weight: 300;
}
/* ── KR 时间轴 ── */
.kr-timeline {
background: #FAFBFC;
border: 1px solid #EBEEF5;
border-radius: 10px;
padding: 16px;
margin-bottom: 14px;
transition: box-shadow 0.2s;
}
.kr-timeline:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.kr-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.kr-title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.kr-progress-wrap {
flex-shrink: 0;
width: 160px;
}
/* ── 里程碑卡片 ── */
.milestones-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.milestone-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #EBEEF5;
background: #fff;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 180px;
}
.milestone-card:hover {
border-color: #409EFF;
box-shadow: 0 2px 8px rgba(64,158,255,0.12);
}
.ms-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-pending {
background: #C0C4CC;
}
.dot-in_progress {
background: #E6A23C;
animation: pulse 1.5s infinite;
}
.dot-completed {
background: #67C23A;
}
.ms-body {
flex: 1;
min-width: 0;
}
.ms-label {
font-size: 13px;
font-weight: 500;
line-height: 1.3;
}
.ms-month {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.ms-status {
flex-shrink: 0;
}
.no-milestones {
color: #C0C4CC;
font-size: 12px;
padding: 10px 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── 本周行动 ── */
.weekly-section {
margin-top: 4px;
}
.section-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.action-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #EBEEF5;
margin-bottom: 8px;
background: #fff;
transition: border-color 0.2s;
}
.action-item.act-completed {
opacity: 0.65;
background: #F5F7FA;
}
.action-title {
flex: 1;
font-size: 13px;
}
.action-deadline {
font-size: 11px;
color: #909399;
}
.action-tag {
flex-shrink: 0;
}
.empty-section {
color: #C0C4CC;
font-size: 13px;
padding: 20px 0;
text-align: center;
}
</style>
+1
View File
@@ -36,6 +36,7 @@ const routes = [
{ path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } },
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } },
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
{ path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } },
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
+1 -1
View File
@@ -420,7 +420,7 @@ async function loadData() {
async function loadStats() {
try {
const r: any = await actionPlanApi.stats()
stats.value = r
stats.value = { '': r.total || 0, ...r, overdue: r.overdue || 0 }
} catch {}
}
+224
View File
@@ -0,0 +1,224 @@
<template>
<div class="okr-detail-page">
<div class="page-header">
<el-button text @click="$router.back()">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<h3>OKR 详情</h3>
</div>
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="6" animated />
</div>
<template v-if="!loading && objective">
<!-- 基本信息 -->
<div class="info-card">
<div class="info-row">
<span class="info-label">目标</span>
<span class="info-value">{{ objective.objective.title }}</span>
</div>
<div class="info-row">
<span class="info-label">描述</span>
<span class="info-value">{{ objective.objective.description || '无描述' }}</span>
</div>
<div class="info-row">
<span class="info-label">维度</span>
<el-tag size="small" :type="dimTagType(objective.objective.dimension)">
{{ dimLabel(objective.objective.dimension) }}
</el-tag>
</div>
<div class="info-row">
<span class="info-label">季度</span>
<span class="info-value">{{ objective.objective.quarter }}</span>
</div>
<div class="info-row">
<span class="info-label">进度</span>
<el-progress :percentage="objective.objective.progress || 0" :stroke-width="8" style="flex:1;max-width:300px;" />
</div>
<div class="info-row">
<span class="info-label">状态</span>
<el-tag :type="statusTagType(objective.objective.status)">{{ statusLabel(objective.objective.status) }}</el-tag>
</div>
<div class="info-row">
<span class="info-label">负责人</span>
<span class="info-value">{{ objective.objective.owner || '未指定' }}</span>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="KR 列表" name="krs">
<div v-if="!objective.key_results || objective.key_results.length === 0" class="empty-tab">
暂无关联关键结果
</div>
<div v-else class="kr-list">
<div v-for="kr in objective.key_results" :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.assignee" class="kr-meta-item">负责人: {{ kr.assignee }}</span>
<span v-if="kr.due_date" class="kr-meta-item">截止: {{ kr.due_date.slice(0, 10) }}</span>
</div>
<el-progress :percentage="kr.progress || 0" :stroke-width="6" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="分解视图" name="decomposition">
<OkrDecomposition :okr-id="okrId" @refresh="loadData" />
</el-tab-pane>
</el-tabs>
</template>
<div v-if="!loading && !objective" class="not-found">
<el-empty description="OKR不存在" />
</div>
</div>
</template>
<script setup lang="ts">
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 OkrDecomposition from '../components/okr/OkrDecomposition.vue'
const route = useRoute()
const okrId = computed(() => Number(route.params.id))
const loading = ref(true)
const objective = ref<any>(null)
const activeTab = ref('krs')
const DIM_LABELS: Record<string, string> = {
finance: '财务层', customer: '客户层', process: '流程层', learning: '学习成长层',
}
function dimLabel(d: string) { return DIM_LABELS[d] || d }
function dimTagType(d: string) {
const types: Record<string, string> = { finance: 'danger', customer: 'primary', process: 'success', learning: 'warning' }
return types[d] || ''
}
function statusLabel(s: string) {
const labels: Record<string, string> = { active: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[s] || s
}
function statusTagType(s: string) {
const types: Record<string, string> = { active: 'warning', completed: 'success', cancelled: 'danger' }
return types[s] || ''
}
async function loadData() {
if (!okrId.value) return
loading.value = true
try {
const res: any = await okrApi.get(okrId.value)
objective.value = res
} catch (e: any) {
ElMessage.error('加载失败: ' + (e?.response?.data?.detail || e?.message || ''))
objective.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.okr-detail-page {
padding: 16px 20px;
}
.page-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
}
.loading-wrap {
padding: 40px;
}
/* ── 信息卡片 ── */
.info-card {
background: #FAFBFC;
border: 1px solid #EBEEF5;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 20px;
}
.info-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #F0F0F0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 60px;
font-size: 13px;
color: #909399;
flex-shrink: 0;
}
.info-value {
font-size: 13px;
}
/* ── 标签页 ── */
.detail-tabs {
margin-top: 4px;
}
/* ── KR列表 ── */
.kr-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.kr-card {
background: #fff;
border: 1px solid #EBEEF5;
border-radius: 8px;
padding: 14px 16px;
}
.kr-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.kr-title {
font-weight: 500;
font-size: 14px;
}
.kr-meta {
display: flex;
gap: 14px;
margin-bottom: 8px;
}
.kr-meta-item {
font-size: 12px;
color: #909399;
}
.empty-tab {
text-align: center;
color: #C0C4CC;
padding: 40px 0;
font-size: 14px;
}
.not-found {
padding: 60px 0;
}
</style>