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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user