fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器

- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
This commit is contained in:
Hermes CI Fix
2026-07-13 09:52:06 +08:00
parent b26046349c
commit 957dacd248
9 changed files with 994 additions and 507 deletions
@@ -1,64 +1,56 @@
<template>
<div v-if="visible" class="mc-dialog-overlay" @click.self="emit('update:visible', false)">
<div v-if="visible" class="mc-dialog-overlay" @click.self="$emit('close')">
<div class="mc-dialog-box">
<div class="mc-dialog-header">
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
<button class="mc-dialog-close" @click="emit('update:visible', false)">×</button>
<button class="mc-dialog-close" @click="$emit('close')">×</button>
</div>
<div class="mc-dialog-body">
<!-- 目标名称必填 -->
<div class="mc-form-item">
<label>目标名称 <span class="mc-required">*</span></label>
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
</div>
<!-- 描述 -->
<div class="mc-form-item">
<label>描述</label>
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
</div>
<!-- 目标值 -->
<div class="mc-form-item">
<label>目标值</label>
<input v-model.number="localForm.targetValue" class="mc-input" type="number" placeholder="预期达成值" />
</div>
<!-- 当前值 -->
<div class="mc-form-item">
<label>当前值</label>
<input v-model.number="localForm.currentValue" class="mc-input" type="number" placeholder="当前实际值" />
</div>
<!-- 单位 -->
<div class="mc-form-row">
<div class="mc-form-item mc-form-item-flex">
<label>单位</label>
<input v-model="localForm.unit" class="mc-input" placeholder="如:万元、%" />
</div>
<div class="mc-form-item mc-form-item-flex">
<label>责任人</label>
<input v-model="localForm.owner" class="mc-input" placeholder="责任人姓名" />
<label>图标</label>
<div class="mc-icon-picker">
<button
v-for="(ico, key) in iconOptions" :key="key"
class="mc-icon-btn"
:class="{ active: localForm.icon === key }"
@click="localForm.icon = key"
:title="ico"
>{{ iconEmoji(key) }}</button>
</div>
</div>
<!-- 领先/滞后指标 -->
<div class="mc-form-item">
<label>指标类型</label>
<div class="mc-leading-switch">
<label class="mc-radio-label" :class="{ active: !localForm.isLeading }">
<input type="radio" v-model="localForm.isLeading" :value="false" />
滞后指标结果
</label>
<label class="mc-radio-label" :class="{ active: localForm.isLeading }">
<input type="radio" v-model="localForm.isLeading" :value="true" />
领先指标驱动
</label>
<label>关联KPI <span class="mc-hint">最多3个</span></label>
<div class="mc-kpi-select-wrap">
<div class="mc-kpi-tags" v-if="localForm.kpis.length > 0">
<span class="mc-kpi-tag" v-for="(code, i) in localForm.kpis" :key="code">
{{ getKpiName(code) }}
<button class="mc-tag-remove" @click="localForm.kpis.splice(i, 1)">×</button>
</span>
</div>
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
<select class="mc-select mc-select-sm" @change="addKpi($event)" style="flex:1;">
<option value="">+ 选择KPI{{ dimLabel }}</option>
<option v-for="k in availableKpis" :key="k.id" :value="k.kpi_code">{{ k.kpi_name }}</option>
<option value="__new__">+ 新建KPI...</option>
</select>
</div>
<div v-if="allKpis.length === 0" style="color:#999;font-size:12px;">
暂无该维度KPI
<el-button text size="small" type="primary" @click="onNewKpi">点击新建</el-button>
</div>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<button class="mc-btn" @click="emit('update:visible', false)">取消</button>
<button class="mc-btn" @click="$emit('close')">取消</button>
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
</div>
</div>
@@ -66,83 +58,80 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue'
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
visible: boolean
nodeData: any | null
layerKey: string
form: { name: string; description: string; icon: string; kpis: string[] }
dimKey: string
dimLabel: string
isEditing: boolean
allKpis: any[]
kpiNameMap: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'save', data: {
name: string
description: string
targetValue: number | null
currentValue: number | null
unit: string
owner: string
isLeading: boolean
layer: string
icon?: string
kpis?: string[]
}): void
(e: 'close'): void
(e: 'save', form: { name: string; description: string; icon: string; kpis: string[] }): void
(e: 'new-kpi'): void
}>()
const nameInputRef = ref<HTMLElement | null>(null)
const defaultForm = {
name: '',
description: '',
targetValue: null as number | null,
currentValue: null as number | null,
unit: '%',
owner: '',
isLeading: false,
icon: 'target',
kpis: [] as string[],
}
const localForm = reactive({ ...defaultForm })
const isEditing = ref(false)
const localForm = reactive({ name: '', description: '', icon: 'target', kpis: [] as string[] })
watch(() => props.visible, (v) => {
if (v) {
if (props.nodeData) {
// 编辑模式
isEditing.value = true
localForm.name = props.nodeData.name || ''
localForm.description = props.nodeData.description || ''
localForm.targetValue = props.nodeData.targetValue ?? null
localForm.currentValue = props.nodeData.currentValue ?? null
localForm.unit = props.nodeData.unit || '%'
localForm.owner = props.nodeData.owner || ''
localForm.isLeading = props.nodeData.isLeading ?? false
localForm.icon = props.nodeData.icon || 'target'
localForm.kpis = props.nodeData.kpis ? [...props.nodeData.kpis] : []
} else {
// 新增模式 - 根据层智能设置默认单位
isEditing.value = false
Object.assign(localForm, { ...defaultForm })
const layerDefaults: Record<string, { unit: string; isLeading: boolean }> = {
financial: { unit: '万元', isLeading: false },
customer: { unit: '%', isLeading: true },
process: { unit: '%', isLeading: true },
learning: { unit: '%', isLeading: true },
}
const defaults = layerDefaults[props.layerKey]
if (defaults) {
localForm.unit = defaults.unit
localForm.isLeading = defaults.isLeading
}
}
localForm.name = props.form.name
localForm.description = props.form.description
localForm.icon = props.form.icon || 'target'
localForm.kpis = [...(props.form.kpis || [])]
nextTick(() => nameInputRef.value?.focus())
}
})
const iconOptions: Record<string, string> = {
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
}
function iconEmoji(key: string): string {
const map: Record<string, string> = {
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
}
return map[key] || '📌'
}
const availableKpis = computed(() => {
return props.allKpis.filter((k: any) => {
if (k.dimension !== props.dimKey) return false
if (localForm.kpis.includes(k.kpi_code)) return false
return true
})
})
function getKpiName(code: string): string {
return props.kpiNameMap[code] || code
}
function addKpi(e: any) {
const val = e.target.value
if (val === '__new__') {
e.target.value = ''
emit('new-kpi')
return
}
if (val && !localForm.kpis.includes(val) && localForm.kpis.length < 3) {
localForm.kpis.push(val)
}
e.target.value = ''
}
function onNewKpi() {
emit('new-kpi')
}
function onSave() {
if (!localForm.name?.trim()) {
ElMessage.warning('请输入目标名称')
@@ -152,12 +141,6 @@ function onSave() {
emit('save', {
name: localForm.name.trim(),
description: localForm.description?.trim() || '',
targetValue: localForm.targetValue ?? null,
currentValue: localForm.currentValue ?? null,
unit: localForm.unit?.trim() || '%',
owner: localForm.owner?.trim() || '',
isLeading: localForm.isLeading ?? false,
layer: props.layerKey,
icon: localForm.icon || 'target',
kpis: [...localForm.kpis],
})
@@ -193,39 +176,46 @@ function onSave() {
.mc-form-item label {
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
}
.mc-form-row {
display:flex; gap:12px;
}
.mc-form-item-flex {
flex:1;
}
.mc-required { color:#f56c6c; margin-left:2px; }
.mc-hint { font-weight:400; color:#999; font-size:12px; }
.mc-input {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
transition: border-color 0.2s;
}
.mc-input:focus { border-color:#409eff; }
.mc-input[type="number"] { font-variant-numeric: tabular-nums; }
.mc-textarea { resize:vertical; min-height:50px; font-family:inherit; }
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
.mc-select {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; background:#fff; box-sizing:border-box;
}
.mc-select:focus { border-color:#409eff; }
.mc-select-sm { font-size:13px; padding:6px 10px; }
.mc-btn {
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
background:#fff; color:#333; font-size:14px; cursor:pointer;
}
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
.mc-btn:hover { opacity:0.85; }
.mc-leading-switch {
display:flex; gap:12px;
.mc-icon-picker {
display:flex; flex-wrap:wrap; gap:6px;
}
.mc-radio-label {
display:flex; align-items:center; gap:6px;
padding:8px 14px; border:2px solid #e8e8e8; border-radius:8px;
cursor:pointer; font-size:13px; color:#666; transition:all .15s;
flex:1; justify-content:center;
.mc-icon-btn {
width:40px; height:40px; display:flex; align-items:center; justify-content:center;
font-size:20px; border:2px solid #e8e8e8; border-radius:8px;
background:#fff; cursor:pointer; transition:all .15s;
}
.mc-radio-label:hover { border-color:#409eff; }
.mc-radio-label.active {
border-color:#409eff; background:#ecf5ff; color:#409eff; font-weight:500;
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; box-shadow:0 0 0 2px rgba(64,158,255,0.2); }
.mc-kpi-select-wrap { display:flex; flex-direction:column; gap:6px; }
.mc-kpi-tags { display:flex; flex-wrap:wrap; gap:6px; }
.mc-kpi-tag {
display:inline-flex; align-items:center; gap:4px;
padding:4px 10px; background:#ecf5ff; color:#409eff;
border-radius:4px; font-size:12px;
}
.mc-radio-label input { display:none; }
.mc-tag-remove {
background:none; border:none; color:#a0cfff; cursor:pointer;
font-size:14px; padding:0; line-height:1;
}
.mc-tag-remove:hover { color:#f56c6c; }
</style>