Files
cma-management/frontend/src/components/okr/OkrDecomposition.vue
T
Hermes CI Fix 07dc10d557
ci/woodpecker/push/main Pipeline failed
fix(ui): 全仓 el-dialog 清剿 → MyDialog(26 处/17 文件)
- 项目铁律:一律 MyDialog(Element 原生弹窗在本项目长期不可见)
- 含用户报的 OKR 模板库「应用」弹窗(OKRTemplates.vue)
- 等价适配:width 字符串 px → 数字绑定;append-to-body/destroy-on-close
  /top/show-close/class 等 EP 专有属性移除(MyDialog 已 teleport+卸载语义)
- 新增 MyDialog 显式 import;零后端/零数据改动
- 全仓 <el-dialog> 0 命中;components.d.ts 随之移除 ElDialog 自动注册
2026-09-24 17:36:05 +08:00

414 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">
暂无里程碑
<el-button size="small" text type="primary" :loading="generatingKrId === kr.kr_id" @click="autoGenerate(kr)">
一键生成
</el-button>
</div>
<div v-else class="milestone-actions">
<el-button size="small" text type="primary" :loading="generatingKrId === kr.kr_id" @click="autoGenerate(kr)">
重新生成
</el-button>
</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>
<!-- 里程碑编辑弹窗 -->
<MyDialog v-model="showEditDialog" title="编辑里程碑" :width="400">
<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>
</MyDialog>
</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'
import MyDialog from '../MyDialog.vue'
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)
const generatingKrId = ref<number | null>(null)
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 {
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 }
}
// 持久化到后端(里程碑完成时KR进度同步更新)
if (kr) {
const res: any = await okrApi.saveMilestones(props.okrId, kr.kr_id, kr.milestones)
if (res && res.progress != null) {
kr.progress = res.progress
}
}
ElMessage.success('里程碑已保存')
showEditDialog.value = false
emit('refresh')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e?.message || ''))
} finally {
saving.value = false
}
}
async function autoGenerate(kr: any) {
if (!kr) return
generatingKrId.value = kr.kr_id
try {
const res: any = await okrApi.generateMilestones(props.okrId, kr.kr_id)
if (res?.milestones) {
kr.milestones = res.milestones
}
ElMessage.success('已自动生成月度里程碑')
emit('refresh')
} catch (e: any) {
ElMessage.error('生成失败: ' + (e?.response?.data?.detail || e?.message || ''))
} finally {
generatingKrId.value = null
}
}
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;
}
.milestone-actions {
display: flex;
align-items: center;
justify-content: center;
min-width: 90px;
padding: 4px 0;
border-left: 1px dashed #EBEEF5;
}
@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>