feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
<div class="mc-dim-label">{{ dimensionName }}</div>
|
||||
|
||||
<!-- 模板列表 -->
|
||||
<div class="mc-tmpl-list">
|
||||
<div v-if="templateLoading" class="mc-tmpl-empty">模板库加载中...</div>
|
||||
<div v-else class="mc-tmpl-list">
|
||||
<div
|
||||
v-for="(tmpl, idx) in filteredTemplates"
|
||||
:key="idx"
|
||||
@@ -20,7 +21,13 @@
|
||||
>
|
||||
<div class="mc-tmpl-header">
|
||||
<span class="mc-tmpl-name">{{ tmpl.name }}</span>
|
||||
<span v-if="selectedTemplateIdx === idx" class="mc-tmpl-check">✓</span>
|
||||
<span class="mc-tmpl-tags">
|
||||
<el-tag v-if="tmpl.source === 'industry_pack'" size="small" type="warning" style="margin-right:6px;">
|
||||
{{ industryTagLabel(tmpl.industry_tag) }}
|
||||
</el-tag>
|
||||
<el-tag v-else-if="tmpl.source === 'user'" size="small" type="success" style="margin-right:6px;">用户</el-tag>
|
||||
<span v-if="selectedTemplateIdx === idx" class="mc-tmpl-check">✓</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mc-tmpl-desc">{{ tmpl.description }}</div>
|
||||
<div class="mc-tmpl-krs">
|
||||
@@ -104,6 +111,7 @@
|
||||
<div v-if="dialogStep === 1" class="mc-footer-buttons">
|
||||
<el-button size="small" @click="onCancel">取消</el-button>
|
||||
<el-button size="small" plain @click="skipTemplate">✏️ 跳过模板,自定义</el-button>
|
||||
<el-button size="small" text type="primary" @click="goTemplateLibrary">📚 从模板库选择更多</el-button>
|
||||
<el-button size="small" type="primary" @click="nextStep">
|
||||
下一步 {{ selectedTemplateIdx !== null ? '(已选模板)' : '(直接跳过)' }}
|
||||
</el-button>
|
||||
@@ -230,8 +238,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { ref, computed, watch, onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { okrTemplateApi } from "../../api/index"
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
@@ -433,6 +443,9 @@ const DIMENSION_LABELS: Record<string, string> = {
|
||||
const dialogStep = ref(1) // 1 = 模板选择, 2 = O+KR编辑
|
||||
const selectedTemplateIdx = ref<number | null>(null)
|
||||
const usedTemplate = ref<any | null>(null)
|
||||
const backendTemplates = ref<any[]>([]) // 后端模板库(含行业包/用户自定义)
|
||||
const templateLoading = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
// 内部表单(O+KR结构)
|
||||
const form = ref<{
|
||||
@@ -453,13 +466,48 @@ const dimensionName = computed(() => {
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const key = props.editingLayerKey || "finance"
|
||||
return (CMA_TEMPLATES as any)[key] || []
|
||||
const hardcoded = (CMA_TEMPLATES as any)[key] || []
|
||||
// 后端模板库(14个CMA标准 + 行业包 + 用户自定义)按维度过滤,与硬编码去重合并
|
||||
const fromBackend = backendTemplates.value
|
||||
.filter((t: any) => t.dimension === key && t.is_active !== 0)
|
||||
.map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
dimension: t.dimension,
|
||||
source: t.source,
|
||||
industry_tag: t.industry_tag,
|
||||
preset_krs: (t.preset_krs || []).map((kr: any) => ({
|
||||
name: kr.name,
|
||||
target_value: kr.target_value,
|
||||
weight: kr.weight,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
})),
|
||||
}))
|
||||
const seen = new Set<string>()
|
||||
const merged: any[] = []
|
||||
for (const t of [...fromBackend, ...hardcoded]) {
|
||||
if (seen.has(t.name)) continue
|
||||
seen.add(t.name)
|
||||
merged.push(t)
|
||||
}
|
||||
return merged
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return props.isEditing ? "编辑目标" : "添加目标"
|
||||
})
|
||||
|
||||
const INDUSTRY_LABELS: Record<string, string> = {
|
||||
trading: "贸易经销",
|
||||
it_service: "IT服务",
|
||||
manufacturing: "制造业",
|
||||
general: "通用",
|
||||
}
|
||||
function industryTagLabel(tag?: string): string {
|
||||
return (tag && INDUSTRY_LABELS[tag]) || tag || "行业包"
|
||||
}
|
||||
|
||||
const kpiOptions = computed(() => {
|
||||
return props.allKpis || []
|
||||
})
|
||||
@@ -518,6 +566,44 @@ const weightSum = computed(() => {
|
||||
return form.value.krs.reduce((s, kr) => s + (parseInt(kr.weight) || 0), 0)
|
||||
})
|
||||
|
||||
// ── 从后端加载模板库(失败时静默回退到硬编码模板) ──
|
||||
async function loadBackendTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const res: any = await okrTemplateApi.list({})
|
||||
const items = res?.items || []
|
||||
// 后端模板需要标准化为前端结构
|
||||
backendTemplates.value = items.map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
dimension: t.dimension,
|
||||
source: t.source,
|
||||
industry_tag: t.industry_tag,
|
||||
is_active: t.is_active,
|
||||
preset_krs: (t.preset_krs || []).map((kr: any) => ({
|
||||
name: kr.name,
|
||||
target_value: kr.target_value,
|
||||
weight: kr.weight,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
})),
|
||||
}))
|
||||
} catch (e) {
|
||||
// 网络/权限失败:使用硬编码14个CMA模板兜底
|
||||
backendTemplates.value = []
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBackendTemplates()
|
||||
})
|
||||
|
||||
function goTemplateLibrary() {
|
||||
router.push('/okr-templates')
|
||||
}
|
||||
|
||||
function addKr() {
|
||||
if (form.value.krs.length >= 5) {
|
||||
ElMessage.warning('每个目标最多5个关键结果')
|
||||
@@ -576,6 +662,7 @@ function onSave() {
|
||||
o_name: form.value.o_name.trim(),
|
||||
o_description: form.value.o_description.trim(),
|
||||
template_name: usedTemplate.value?.name || null,
|
||||
template_id: usedTemplate.value?.id || null,
|
||||
dimension: props.editingLayerKey,
|
||||
krs: form.value.krs.map((kr, i) => ({
|
||||
name: kr.name,
|
||||
@@ -588,6 +675,11 @@ function onSave() {
|
||||
name: form.value.o_name.trim(),
|
||||
description: form.value.o_description.trim(),
|
||||
})
|
||||
|
||||
// 模板使用次数 +1(仅后端来源的模板,静默失败不影响保存)
|
||||
if (usedTemplate.value?.id) {
|
||||
okrTemplateApi.incrementUse(usedTemplate.value.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
</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>
|
||||
@@ -119,6 +127,7 @@ 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 ''
|
||||
@@ -163,21 +172,44 @@ 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('里程碑已更新')
|
||||
// 持久化到后端(里程碑完成时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?.message || ''))
|
||||
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
|
||||
@@ -319,6 +351,14 @@ onMounted(() => {
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user