ci/woodpecker/push/main Pipeline failed
- 项目铁律:一律 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 自动注册
414 lines
10 KiB
Vue
414 lines
10 KiB
Vue
<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>
|