feat: CMA前端UI优化第三轮P2 — mc-dialog→el-dialog+按钮统一+MapCanvas拆组件+全局过渡动画
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="my-dialog" :style="{ width: width + 'px' }">
|
||||
<div class="my-dialog-header">
|
||||
<span class="my-dialog-title">{{ title }}</span>
|
||||
<button class="my-dialog-close" @click="close">×</button>
|
||||
<el-button size="small" text @click="close">×</el-button>
|
||||
</div>
|
||||
<div class="my-dialog-body">
|
||||
<slot />
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<!-- 节点编辑弹窗 -->
|
||||
<el-dialog :model-value="showNodeDialog" @update:model-value="$emit('update:showNodeDialog', $event)" :title="isEditing ? '编辑目标' : '添加目标'" width="520px" :close-on-click-modal="true">
|
||||
<div class="mc-dialog-body">
|
||||
<div class="mc-form-item">
|
||||
<label>目标名称 <span class="mc-required">*</span></label>
|
||||
<input v-model="form.name" class="mc-input" placeholder="请输入目标名称" ref="dialogNameRef" />
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>描述</label>
|
||||
<textarea v-model="form.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>图标</label>
|
||||
<div class="mc-icon-picker">
|
||||
<button v-for="(ico, key) in iconOptions" :key="key"
|
||||
class="mc-icon-btn" :class="{ active: form.icon === key }"
|
||||
@click="form.icon = key" :title="ico">{{ iconEmoji(key) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>目标值 <span class="mc-hint">(CMA字段)</span></label>
|
||||
<input v-model="form.targetValue" class="mc-input" type="number" placeholder="目标值" />
|
||||
</div>
|
||||
<div class="mc-row">
|
||||
<input v-model="form.currentValue" class="mc-input mc-input-half" type="number" placeholder="当前值" />
|
||||
<input v-model="form.unit" class="mc-input mc-input-half" placeholder="单位(如%、万元)" />
|
||||
</div>
|
||||
<div class="mc-row">
|
||||
<input v-model="form.owner" class="mc-input" placeholder="责任人" />
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>指标类型</label>
|
||||
<label class="mc-radio-label"><input type="radio" v-model="form.isLeading" :value="false" /> 滞后指标(结果)</label>
|
||||
<label class="mc-radio-label"><input type="radio" v-model="form.isLeading" :value="true" /> 领先指标(驱动)</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="$emit('update:showNodeDialog', false)">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="$emit('save-node-dialog')">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- KPI因果链推荐弹窗 -->
|
||||
<el-dialog :model-value="showCausalityDialog" @update:model-value="$emit('update:showCausalityDialog', $event)" title="🔗 KPI因果链推荐" width="680px">
|
||||
<div style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="causalityLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
|
||||
<div v-else-if="causalityRecommendations.length === 0" style="text-align:center;padding:20px;color:#999;">暂无因果链推荐</div>
|
||||
<div v-else>
|
||||
<div v-for="(rec,idx) in causalityRecommendations" :key="idx" style="padding:10px 0;border-bottom:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>{{ rec.source_kpi_code || rec.source_code }}</strong>
|
||||
<span style="color:#999;margin:0 8px;">→</span>
|
||||
<strong>{{ rec.target_kpi_code || rec.target_code }}</strong>
|
||||
<span style="margin-left:8px;font-size:12px;color:#666;">强度: {{ rec.strength || '-' }}</span>
|
||||
</div>
|
||||
<el-button size="small" @click="$emit('apply-causality', rec)">应用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="$emit('update:showCausalityDialog', false)">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="$emit('apply-all-causality')">一键全部应用</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 版本历史弹窗 -->
|
||||
<el-dialog :model-value="showVersions" @update:model-value="$emit('update:showVersions', $event)" title="版本历史" width="620px">
|
||||
<div style="max-height:400px;overflow-y:auto;">
|
||||
<table class="mc-table" v-if="versions.length > 0">
|
||||
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.id">
|
||||
<td>{{ v.comment || '-' }}</td>
|
||||
<td>{{ v.created_at }}</td>
|
||||
<td><el-button size="small" type="warning" @click="$emit('rollback-version', v.id)">回滚</el-button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- KPI详情浮层 -->
|
||||
<el-dialog :model-value="showKpiDetail" @update:model-value="$emit('update:showKpiDetail', $event)" :title="'📊 ' + kpiDetailObjName + ' — KPI详情'" width="560px">
|
||||
<div style="max-height:360px;overflow-y:auto;">
|
||||
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
该目标暂未关联KPI
|
||||
</div>
|
||||
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
|
||||
<div class="kpi-detail-top">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
|
||||
<span class="kpi-detail-code">{{ item.code }}</span>
|
||||
<span class="kpi-detail-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-values">
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">实际值</span>
|
||||
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
|
||||
</div>
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">目标值</span>
|
||||
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="item.actual != null && item.target" class="kdv-item">
|
||||
<span class="kdv-label">达成率</span>
|
||||
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
|
||||
{{ (item.actual / item.target * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.unit" class="kdv-item">
|
||||
<span class="kdv-label">单位</span>
|
||||
<span class="kdv-val">{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="$emit('update:showKpiDetail', false)">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="$emit('edit-kpi-obj')">编辑目标</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 行动方案列表弹窗 -->
|
||||
<el-dialog :model-value="showPlanPopup" @update:model-value="$emit('update:showPlanPopup', $event)" :title="'📋 ' + planPopupObjName + ' — 关联行动方案'" width="600px">
|
||||
<div style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
暂无关联的行动方案
|
||||
</div>
|
||||
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
|
||||
<div class="plan-popup-top">
|
||||
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
|
||||
<span class="plan-popup-title">{{ p.title }}</span>
|
||||
</div>
|
||||
<div class="plan-popup-meta">
|
||||
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
|
||||
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
|
||||
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="$emit('update:showPlanPopup', false)">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="$emit('quick-create-plan')">+ 新建行动方案</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
showNodeDialog: boolean
|
||||
showCausalityDialog: boolean
|
||||
showVersions: boolean
|
||||
showKpiDetail: boolean
|
||||
showPlanPopup: boolean
|
||||
isEditing: boolean
|
||||
form: any
|
||||
causalityLoading: boolean
|
||||
causalityRecommendations: any[]
|
||||
versions: any[]
|
||||
kpiDetailObjName: string
|
||||
kpiDetailList: any[]
|
||||
planPopupObjName: string
|
||||
planPopupList: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:showNodeDialog', v: boolean): void
|
||||
(e: 'update:showCausalityDialog', v: boolean): void
|
||||
(e: 'update:showVersions', v: boolean): void
|
||||
(e: 'update:showKpiDetail', v: boolean): void
|
||||
(e: 'update:showPlanPopup', v: boolean): void
|
||||
(e: 'save-node-dialog'): void
|
||||
(e: 'apply-causality', rec: any): void
|
||||
(e: 'apply-all-causality'): void
|
||||
(e: 'rollback-version', id: number): void
|
||||
(e: 'edit-kpi-obj'): void
|
||||
(e: 'quick-create-plan'): void
|
||||
}>()
|
||||
|
||||
const iconOptions: Record<string, string> = {
|
||||
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
|
||||
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
|
||||
}
|
||||
|
||||
function iconEmoji(key: string): string {
|
||||
const m: Record<string, string> = {
|
||||
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
|
||||
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
|
||||
}
|
||||
return m[key] || '📌'
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
if (ratio >= 0.7) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function fmtKpiVal(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function badgeIcon(level: string): string {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
function planStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mc-dialog-body { padding: 0; }
|
||||
.mc-form-item { margin-bottom:16px; }
|
||||
.mc-form-item label { display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500; }
|
||||
.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; }
|
||||
.mc-input:focus { border-color:#409eff; }
|
||||
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
|
||||
.mc-input-half { width:48%; }
|
||||
.mc-row { display:flex; gap:4%; margin-bottom:16px; }
|
||||
.mc-icon-picker { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.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; }
|
||||
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
|
||||
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; }
|
||||
.mc-radio-label { display:inline-flex; align-items:center; gap:4px; margin-right:16px; font-size:13px; color:#666; cursor:pointer; }
|
||||
.mc-radio-label input { margin:0; }
|
||||
.mc-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mc-table th, .mc-table td { padding:10px 12px; text-align:left; border-bottom:1px solid #f0f0f0; }
|
||||
.mc-table th { background:#fafafa; font-weight:600; color:#666; font-size:12px; }
|
||||
.mc-table tr:hover td { background:#f5f7fa; }
|
||||
.kpi-detail-card { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
||||
.kpi-detail-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-detail-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-detail-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-detail-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-badge { font-size: 14px; }
|
||||
.kpi-detail-code { font-size: 11px; color: #999; font-family: monospace; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-values { padding-left: 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.kdv-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.kdv-label { color: #999; width: 56px; }
|
||||
.kdv-val { font-weight: 500; }
|
||||
.plan-popup-card { border: 1px solid #eee; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px; }
|
||||
.plan-popup-card:hover { background: #fafafa; }
|
||||
.plan-popup-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.plan-popup-status { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0; }
|
||||
.pps-pending { background: #f5f7fa; color: #909399; }
|
||||
.pps-in_progress { background: #fdf6ec; color: #e6a23c; }
|
||||
.pps-completed { background: #f0f9eb; color: #67c23a; }
|
||||
.pps-cancelled { background: #f5f7fa; color: #bbb; }
|
||||
.plan-popup-title { font-weight: 500; font-size: 13px; }
|
||||
.plan-popup-meta { display: flex; gap: 12px; font-size: 11px; color: #999; padding-left: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<h3>战略地图画布 — 四层泳道</h3>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="selectedMapLocal" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="$emit('select-map', selectedMapLocal)">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls" title="画布缩放">
|
||||
<el-button size="small" @click="$emit('zoom-out')" :disabled="zoomLevel <= 0.5">−</el-button>
|
||||
<span class="zoom-label">{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<el-button size="small" @click="$emit('zoom-in')" :disabled="zoomLevel >= 1.5">+</el-button>
|
||||
<el-button size="small" @click="$emit('zoom-reset')">100%</el-button>
|
||||
<el-button size="small" @click="$emit('zoom-fit')">适合</el-button>
|
||||
</div>
|
||||
<!-- 视角切换 -->
|
||||
<span class="view-toggle">
|
||||
<label class="vt-label" :class="{ active: viewDirection === 'cascade' }">
|
||||
<input type="radio" :checked="viewDirection === 'cascade'" @change="$emit('update:viewDirection', 'cascade')" /> 📐 级联
|
||||
</label>
|
||||
<label class="vt-label" :class="{ active: viewDirection === 'causal' }">
|
||||
<input type="radio" :checked="viewDirection === 'causal'" @change="$emit('update:viewDirection', 'causal')" /> 🔗 因果
|
||||
</label>
|
||||
</span>
|
||||
<el-button @click="$emit('show-causality')">🔗 因果链推荐</el-button>
|
||||
<el-button type="primary" @click="$emit('save-canvas')">保存</el-button>
|
||||
<el-button type="success" @click="$emit('publish-map')" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||
<el-button @click="$emit('show-versions')">版本历史</el-button>
|
||||
<el-button @click="$emit('go-alignment')" type="info" plain>
|
||||
<svg style="width:14px;height:14px;margin-right:4px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l2-3 3 3 4-7 3 5 2-2 4 4"/>
|
||||
</svg>
|
||||
查看执行看板
|
||||
</el-button>
|
||||
<el-button @click="$emit('export-image')">📷 导出图片</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||
<el-button size="small" @click="$emit('cancel-link')">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
maps: any[]
|
||||
selectedMap: number | null
|
||||
zoomLevel: number
|
||||
viewDirection: string
|
||||
currentMap: any
|
||||
linkingFrom: { key: string; obj: any } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-map', id: number): void
|
||||
(e: 'update:viewDirection', v: string): void
|
||||
(e: 'zoom-in'): void
|
||||
(e: 'zoom-out'): void
|
||||
(e: 'zoom-reset'): void
|
||||
(e: 'zoom-fit'): void
|
||||
(e: 'export-image'): void
|
||||
(e: 'show-causality'): void
|
||||
(e: 'save-canvas'): void
|
||||
(e: 'publish-map'): void
|
||||
(e: 'show-versions'): void
|
||||
(e: 'go-alignment'): void
|
||||
(e: 'cancel-link'): void
|
||||
}>()
|
||||
|
||||
const selectedMapLocal = ref(props.selectedMap)
|
||||
watch(() => props.selectedMap, (v) => { selectedMapLocal.value = v })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.zoom-controls { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
|
||||
.zoom-controls .el-button { padding: 4px 8px !important; font-size: 13px; min-height: auto; }
|
||||
.zoom-label { display: inline-block; min-width: 32px; text-align: center; font-size: 12px; color: #666; font-variant-numeric: tabular-nums; }
|
||||
.linking-bar { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1; border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0; }
|
||||
.view-toggle { display:inline-flex; align-items:center; gap:0; margin-right:8px; border:1px solid #dcdfe6; border-radius:4px; overflow:hidden; }
|
||||
.vt-label { padding:5px 12px; font-size:12px; cursor:pointer; color:#606266; background:#fff; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
|
||||
.vt-label input { display:none; }
|
||||
.vt-label.active { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.vt-label:not(:last-child) { border-right:1px solid #dcdfe6; }
|
||||
.vt-label:hover:not(.active) { color:#409eff; }
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="mc-dialog-box">
|
||||
<div class="mc-dialog-header">
|
||||
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
|
||||
<button class="mc-dialog-close" @click="$emit('close')">×</button>
|
||||
<el-button size="small" text @click="$emit('close')">×</el-button>
|
||||
</div>
|
||||
<div class="mc-dialog-body">
|
||||
<div class="mc-form-item">
|
||||
@@ -32,7 +32,7 @@
|
||||
<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>
|
||||
<el-button size="small" text @click="localForm.kpis.splice(i, 1)">×</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
|
||||
@@ -50,8 +50,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<button class="mc-btn" @click="$emit('close')">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
|
||||
<el-button size="small" @click="$emit('close')">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="onSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-26
@@ -266,32 +266,6 @@ html.dark .el-dialog__body {
|
||||
color: var(--bh-text-regular);
|
||||
}
|
||||
|
||||
/* 自定义弹窗暗黑适配 */
|
||||
html.dark .mc-dialog-overlay,
|
||||
html.dark .mc-dialog-overlay-fix {
|
||||
background: rgba(0,0,0,0.7);
|
||||
}
|
||||
html.dark .mc-dialog-box {
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
html.dark .mc-dialog-header {
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
html.dark .mc-dialog-footer {
|
||||
border-top-color: #333;
|
||||
}
|
||||
html.dark .mc-input {
|
||||
background: #252525;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 自定义弹窗内下拉选择框层级修复 */
|
||||
.dialog-select-popper {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
|
||||
/* el-empty 暗黑适配 */
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--bh-text-secondary);
|
||||
@@ -404,3 +378,13 @@ html *::after {
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* 路由过渡动画 — fade */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
<template>
|
||||
<div class="map-canvas-page" ref="canvasRef">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<h3>战略地图画布 — 四层泳道</h3>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadCanvas">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls" title="画布缩放">
|
||||
<el-button size="small" @click="zoomOut" :disabled="zoomLevel <= 0.5">−</el-button>
|
||||
<span class="zoom-label">{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<el-button size="small" @click="zoomIn" :disabled="zoomLevel >= 1.5">+</el-button>
|
||||
<el-button size="small" @click="zoomReset">100%</el-button>
|
||||
<el-button size="small" @click="zoomFit">适合</el-button>
|
||||
</div>
|
||||
<!-- 视角切换(原生radio) -->
|
||||
<span class="view-toggle">
|
||||
<label class="vt-label" :class="{ active: viewDirection === 'cascade' }">
|
||||
<input type="radio" v-model="viewDirection" value="cascade" /> 📐 级联
|
||||
</label>
|
||||
<label class="vt-label" :class="{ active: viewDirection === 'causal' }">
|
||||
<input type="radio" v-model="viewDirection" value="causal" /> 🔗 因果
|
||||
</label>
|
||||
</span>
|
||||
<el-button @click="exportImage">📷 导出图片</el-button>
|
||||
<el-button @click="showCausalityRecommendations">🔗 因果链推荐</el-button>
|
||||
<el-button type="primary" @click="saveCanvas(false)">保存</el-button>
|
||||
<el-button type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
|
||||
<el-button @click="goAlignment" type="info" plain>
|
||||
<svg style="width:14px;height:14px;margin-right:4px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l2-3 3 3 4-7 3 5 2-2 4 4"/>
|
||||
</svg>
|
||||
查看执行看板
|
||||
</el-button>
|
||||
<el-button @click="exportImage">📷 导出图片</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<MapCanvasToolbar
|
||||
:maps="maps"
|
||||
:selected-map="selectedMap"
|
||||
:zoom-level="zoomLevel"
|
||||
:view-direction="viewDirection"
|
||||
:current-map="currentMap"
|
||||
:linking-from="linkingFrom"
|
||||
@select-map="onSelectMap"
|
||||
@update:view-direction="v => viewDirection = v"
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@zoom-reset="zoomReset"
|
||||
@zoom-fit="zoomFit"
|
||||
@export-image="exportImage"
|
||||
@show-causality="showCausalityRecommendations"
|
||||
@save-canvas="saveCanvas(false)"
|
||||
@publish-map="publishMap"
|
||||
@show-versions="showVersions = true; loadVersions()"
|
||||
@go-alignment="goAlignment"
|
||||
@cancel-link="cancelLink"
|
||||
/>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
@@ -107,7 +90,7 @@
|
||||
</div><!-- end canvas-body -->
|
||||
</div><!-- end canvas-scroll-wrap -->
|
||||
|
||||
<!-- SVG 连线层 (覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
ref="connectionLinesRef"
|
||||
:layer-refs="layerRefs"
|
||||
@@ -121,181 +104,34 @@
|
||||
@delete-connection="onDeleteConnection"
|
||||
/>
|
||||
|
||||
<!-- 节点编辑弹窗(内联,v-show替代v-if) -->
|
||||
<div v-show="showNodeDialog" class="mc-dialog-overlay-fix" @click.self="showNodeDialog=false">
|
||||
<div class="mc-dialog-box">
|
||||
<div class="mc-dialog-header">
|
||||
<span>{{ editingNodeData?._index != null ? '编辑目标' : '添加目标' }}</span>
|
||||
<button class="mc-dialog-close" @click="showNodeDialog=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body">
|
||||
<div class="mc-form-item">
|
||||
<label>目标名称 <span class="mc-required">*</span></label>
|
||||
<input v-model="dialogForm.name" class="mc-input" placeholder="请输入目标名称" ref="dialogNameRef" />
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>描述</label>
|
||||
<textarea v-model="dialogForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>图标</label>
|
||||
<div class="mc-icon-picker">
|
||||
<button v-for="(ico, key) in iconOptions" :key="key"
|
||||
class="mc-icon-btn" :class="{ active: dialogForm.icon === key }"
|
||||
@click="dialogForm.icon = key" :title="ico">{{ iconEmoji(key) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>目标值 <span class="mc-hint">(CMA字段)</span></label>
|
||||
<input v-model="dialogForm.targetValue" class="mc-input" type="number" placeholder="目标值" />
|
||||
</div>
|
||||
<div class="mc-row">
|
||||
<input v-model="dialogForm.currentValue" class="mc-input mc-input-half" type="number" placeholder="当前值" />
|
||||
<input v-model="dialogForm.unit" class="mc-input mc-input-half" placeholder="单位(如%、万元)" />
|
||||
</div>
|
||||
<div class="mc-row">
|
||||
<input v-model="dialogForm.owner" class="mc-input" placeholder="责任人" />
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>指标类型</label>
|
||||
<label class="mc-radio-label"><input type="radio" v-model="dialogForm.isLeading" :value="false" /> 滞后指标(结果)</label>
|
||||
<label class="mc-radio-label"><input type="radio" v-model="dialogForm.isLeading" :value="true" /> 领先指标(驱动)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<button class="mc-btn" @click="showNodeDialog=false">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="onSaveDialog">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- KPI因果链推荐弹窗 -->
|
||||
<div v-show="showCausalityDialog" class="mc-dialog-overlay" @click.self="showCausalityDialog=false">
|
||||
<div class="mc-dialog-box" style="width:680px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>🔗 KPI因果链推荐</span>
|
||||
<button class="mc-dialog-close" @click="showCausalityDialog=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="causalityLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
|
||||
<div v-else-if="causalityRecommendations.length === 0" style="text-align:center;padding:20px;color:#999;">暂无因果链推荐</div>
|
||||
<div v-else>
|
||||
<div v-for="(rec,idx) in causalityRecommendations" :key="idx" style="padding:10px 0;border-bottom:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>{{ rec.source_kpi_code || rec.source_code }}</strong>
|
||||
<span style="color:#999;margin:0 8px;">→</span>
|
||||
<strong>{{ rec.target_kpi_code || rec.target_code }}</strong>
|
||||
<span style="margin-left:8px;font-size:12px;color:#666;">强度: {{ rec.strength || '-' }}</span>
|
||||
</div>
|
||||
<button class="mc-btn" @click="applyCausality(rec)">应用</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer" v-if="causalityRecommendations.length > 0">
|
||||
<button class="mc-btn" @click="showCausalityDialog=false">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="applyAllCausality">一键全部应用</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 版本历史弹窗 -->
|
||||
<div v-show="showVersions" class="mc-dialog-overlay" @click.self="showVersions=false">
|
||||
<div class="mc-dialog-box" style="width:620px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>版本历史</span>
|
||||
<button class="mc-dialog-close" @click="showVersions=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="mc-table" v-if="versions.length > 0">
|
||||
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.id">
|
||||
<td>{{ v.comment || '-' }}</td>
|
||||
<td>{{ v.created_at }}</td>
|
||||
<td><button class="mc-btn mc-btn-sm mc-btn-warn" @click="rollback(v.id)">回滚</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI详情浮层 -->
|
||||
<div v-if="showKpiDetail" class="mc-dialog-overlay" @click.self="showKpiDetail=false">
|
||||
<div class="mc-dialog-box" style="width:560px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📊 {{ kpiDetailObjName }} — KPI详情</span>
|
||||
<button class="mc-dialog-close" @click="showKpiDetail=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:360px;overflow-y:auto;">
|
||||
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
该目标暂未关联KPI
|
||||
</div>
|
||||
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
|
||||
<div class="kpi-detail-top">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
|
||||
<span class="kpi-detail-code">{{ item.code }}</span>
|
||||
<span class="kpi-detail-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-values">
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">实际值</span>
|
||||
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
|
||||
</div>
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">目标值</span>
|
||||
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="item.actual != null && item.target" class="kdv-item">
|
||||
<span class="kdv-label">达成率</span>
|
||||
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
|
||||
{{ (item.actual / item.target * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.unit" class="kdv-item">
|
||||
<span class="kdv-label">单位</span>
|
||||
<span class="kdv-val">{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showKpiDetail=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="editKpiDetailObj">编辑目标</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 行动方案列表弹窗 -->
|
||||
<div v-if="showPlanPopup" class="mc-dialog-overlay" @click.self="showPlanPopup=false">
|
||||
<div class="mc-dialog-box" style="width:600px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📋 {{ planPopupObjName }} — 关联行动方案</span>
|
||||
<button class="mc-dialog-close" @click="showPlanPopup=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
暂无关联的行动方案
|
||||
</div>
|
||||
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
|
||||
<div class="plan-popup-top">
|
||||
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
|
||||
<span class="plan-popup-title">{{ p.title }}</span>
|
||||
</div>
|
||||
<div class="plan-popup-meta">
|
||||
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
|
||||
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
|
||||
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showPlanPopup=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="quickCreatePlan">+ 新建行动方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 所有弹窗统一管理 -->
|
||||
<MapCanvasDialogs
|
||||
:show-node-dialog="showNodeDialog"
|
||||
:show-causality-dialog="showCausalityDialog"
|
||||
:show-versions="showVersions"
|
||||
:show-kpi-detail="showKpiDetail"
|
||||
:show-plan-popup="showPlanPopup"
|
||||
:is-editing="editingNodeData?._index != null"
|
||||
:form="dialogForm"
|
||||
:causality-loading="causalityLoading"
|
||||
:causality-recommendations="causalityRecommendations"
|
||||
:versions="versions"
|
||||
:kpi-detail-obj-name="kpiDetailObjName"
|
||||
:kpi-detail-list="kpiDetailList"
|
||||
:plan-popup-obj-name="planPopupObjName"
|
||||
:plan-popup-list="planPopupList"
|
||||
@update:show-node-dialog="v => showNodeDialog = v"
|
||||
@update:show-causality-dialog="v => showCausalityDialog = v"
|
||||
@update:show-versions="v => showVersions = v"
|
||||
@update:show-kpi-detail="v => showKpiDetail = v"
|
||||
@update:show-plan-popup="v => showPlanPopup = v"
|
||||
@save-node-dialog="onSaveDialog"
|
||||
@apply-causality="applyCausality"
|
||||
@apply-all-causality="applyAllCausality"
|
||||
@rollback-version="rollback"
|
||||
@edit-kpi-obj="editKpiDetailObj"
|
||||
@quick-create-plan="quickCreatePlan"
|
||||
/>
|
||||
</div>
|
||||
<!-- P1-3: 关联知识点 -->
|
||||
<div style="margin-top:12px;">
|
||||
@@ -312,6 +148,8 @@ import api from "../api/index"
|
||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
|
||||
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
|
||||
import MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
|
||||
import html2canvas from "html2canvas"
|
||||
|
||||
// ── 四层泳道配置 ──
|
||||
@@ -357,11 +195,6 @@ const editingNodeData = ref<any>(null)
|
||||
const editingLayerKey = ref('')
|
||||
const dialogRefreshKey = ref(0)
|
||||
const dialogForm = reactive({ name: '', description: '', icon: 'target', targetValue: null, currentValue: null, unit: '%', owner: '', isLeading: false, kpis: [] as string[] })
|
||||
const iconOptions: Record<string, string> = { target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队', light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌' }
|
||||
function iconEmoji(key: string): string {
|
||||
const m: Record<string, string> = { target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥', light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅' }
|
||||
return m[key] || '📌'
|
||||
}
|
||||
function onSaveDialog() {
|
||||
if (!dialogForm.name?.trim()) { ElMessage.warning('请输入目标名称'); return }
|
||||
onNodeSave({ ...dialogForm, name: dialogForm.name.trim(), layer: editingLayerKey.value })
|
||||
@@ -370,6 +203,10 @@ function onSaveDialog() {
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
|
||||
const showCausalityDialog = ref(false)
|
||||
const causalityRecommendations = ref<any[]>([])
|
||||
const causalityLoading = ref(false)
|
||||
|
||||
// DOM引用
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const layerRefs: Record<string, HTMLElement> = {}
|
||||
@@ -378,6 +215,9 @@ const connectionLinesRef = ref<any>(null)
|
||||
const recalcTrigger = ref(0)
|
||||
const viewDirection = ref('cascade')
|
||||
|
||||
// computed node refs for ConnectionLines
|
||||
const compiledNodeRefs = computed(() => nodeRefs)
|
||||
|
||||
watch(viewDirection, () => {
|
||||
recalcTrigger.value++
|
||||
})
|
||||
@@ -403,8 +243,6 @@ function collectAllNodeRefs(): Record<string, any> {
|
||||
return nodeRefs
|
||||
}
|
||||
|
||||
// 拖拽引用(由 vuedraggable 接管,内部不维护)
|
||||
|
||||
// KPI详情浮层
|
||||
const showKpiDetail = ref(false)
|
||||
const kpiDetailObjName = ref("")
|
||||
@@ -459,10 +297,25 @@ function ensureFourLayers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 地图选择 ──
|
||||
function onSelectMap(id: number) {
|
||||
selectedMap.value = id
|
||||
loadCanvas()
|
||||
}
|
||||
|
||||
// ── 缩放 ──
|
||||
function zoomIn() { zoomLevel.value = Math.min(1.5, Math.round((zoomLevel.value + 0.1) * 10) / 10) }
|
||||
function zoomOut() { zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10) }
|
||||
function zoomReset() { zoomLevel.value = 1 }
|
||||
function zoomFit() {
|
||||
// 自适应缩放:大致按视口高度计算
|
||||
if (!scrollWrapRef.value) return
|
||||
const wrapH = scrollWrapRef.value.clientHeight
|
||||
const contentH = scrollWrapRef.value.scrollHeight
|
||||
if (contentH > 0 && wrapH > 0) {
|
||||
zoomLevel.value = Math.max(0.5, Math.min(1.5, Math.round((wrapH / contentH) * 10) / 10))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画布加载 ──
|
||||
function loadCanvas() {
|
||||
@@ -552,11 +405,6 @@ function onLayerReorder(dimKey: string) {
|
||||
}
|
||||
|
||||
function completeConnection(fromKey: string, toKey: string) {
|
||||
const fromDim = fromKey.split('-')[0]
|
||||
const toDim = toKey.split('-')[0]
|
||||
if (fromDim === toDim) {
|
||||
// 同层连线 -> 同层右侧箭头
|
||||
}
|
||||
// 检查重复
|
||||
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
@@ -884,29 +732,6 @@ function editKpiDetailObj() {
|
||||
}
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
if (ratio >= 0.7) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function fmtKpiVal(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function badgeIcon(level: string): string {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
// ── 行动方案 ──
|
||||
async function showPlanList(dimKey: string, oi: number, obj: any) {
|
||||
planPopupObjName.value = obj.name
|
||||
@@ -931,11 +756,6 @@ async function showPlanList(dimKey: string, oi: number, obj: any) {
|
||||
showPlanPopup.value = true
|
||||
}
|
||||
|
||||
function planStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function quickCreatePlan() {
|
||||
showPlanPopup.value = false
|
||||
window.location.href = `/action-plans?dimension=${planPopupDimKey.value}`
|
||||
@@ -966,11 +786,8 @@ async function exportImage() {
|
||||
ElMessage.error('导出失败')
|
||||
}
|
||||
}
|
||||
// ── KPI因果链推荐 ──
|
||||
const showCausalityDialog = ref(false)
|
||||
const causalityRecommendations = ref<any[]>([])
|
||||
const causalityLoading = ref(false)
|
||||
|
||||
// ── KPI因果链推荐 ──
|
||||
async function showCausalityRecommendations() {
|
||||
if (!currentMap.value) { ElMessage.warning('请先选择或创建战略地图'); return }
|
||||
causalityLoading.value = true
|
||||
@@ -991,7 +808,7 @@ async function applyCausality(rec: any) {
|
||||
await api.post(`/maps/${currentMap.value?.id}/connections`, { from: rec.source_kpi_code, to: rec.target_kpi_code })
|
||||
ElMessage.success('连线已添加')
|
||||
showCausalityDialog.value = false
|
||||
loadMap()
|
||||
loadCanvas()
|
||||
} catch { ElMessage.error('添加失败') }
|
||||
}
|
||||
|
||||
@@ -1005,10 +822,9 @@ async function applyAllCausality() {
|
||||
}
|
||||
ElMessage.success(`已添加 ${added}/${causalityRecommendations.value.length} 条因果链`)
|
||||
showCausalityDialog.value = false
|
||||
loadMap()
|
||||
loadCanvas()
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
maps.value = r.data || []
|
||||
@@ -1041,13 +857,6 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* ── 页面布局 ── */
|
||||
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.zoom-controls { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
|
||||
.zoom-controls .el-button { padding: 4px 8px !important; font-size: 13px; min-height: auto; }
|
||||
.zoom-label { display: inline-block; min-width: 32px; text-align: center; font-size: 12px; color: #666; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.linking-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
@@ -1087,102 +896,4 @@ onUnmounted(() => {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 重新调用的样式(从旧MapCanvas复用) ── */
|
||||
.kpi-detail-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||
}
|
||||
.kpi-detail-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-detail-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-detail-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-detail-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-badge { font-size: 14px; }
|
||||
.kpi-detail-code { font-size: 11px; color: #999; font-family: monospace; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-values { padding-left: 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.kdv-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.kdv-label { color: #999; width: 56px; }
|
||||
.kdv-val { font-weight: 500; }
|
||||
|
||||
.plan-popup-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
|
||||
}
|
||||
.plan-popup-card:hover { background: #fafafa; }
|
||||
.plan-popup-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.plan-popup-status {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
}
|
||||
.pps-pending { background: #f5f7fa; color: #909399; }
|
||||
.pps-in_progress { background: #fdf6ec; color: #e6a23c; }
|
||||
.pps-completed { background: #f0f9eb; color: #67c23a; }
|
||||
.pps-cancelled { background: #f5f7fa; color: #bbb; }
|
||||
.plan-popup-title { font-weight: 500; font-size: 13px; }
|
||||
.plan-popup-meta { display: flex; gap: 12px; font-size: 11px; color: #999; padding-left: 4px; }
|
||||
|
||||
/* ── 自定义弹窗(从旧MapCanvas复用) ── */
|
||||
.mc-dialog-overlay {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-dialog-box {
|
||||
background:#fff; border-radius:12px; width:520px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
|
||||
}
|
||||
.mc-dialog-header {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
padding:16px 20px; border-bottom:1px solid #f0f0f0;
|
||||
font-weight:600; font-size:16px;
|
||||
}
|
||||
.mc-dialog-close {
|
||||
background:none; border:none; font-size:22px; color:#999;
|
||||
cursor:pointer; padding:0 4px; line-height:1;
|
||||
}
|
||||
.mc-dialog-close:hover { color:#333; }
|
||||
.mc-dialog-body { padding:20px; }
|
||||
.mc-dialog-footer {
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px 20px; border-top:1px solid #f0f0f0;
|
||||
}
|
||||
.mc-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mc-table th, .mc-table td { padding:10px 12px; text-align:left; border-bottom:1px solid #f0f0f0; }
|
||||
.mc-table th { background:#fafafa; font-weight:600; color:#666; font-size:12px; }
|
||||
.mc-table tr:hover td { background:#f5f7fa; }
|
||||
.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-sm { padding:5px 12px; font-size:12px; }
|
||||
.mc-btn-warn { color:#e6a23c; border-color:#e6a23c; }
|
||||
.mc-btn-warn:hover { background:#e6a23c; color:#fff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
|
||||
/* 内联dialog样式 */
|
||||
.mc-dialog-overlay-fix {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-form-item { margin-bottom:16px; }
|
||||
.mc-form-item label { display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500; }
|
||||
.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; }
|
||||
.mc-input:focus { border-color:#409eff; }
|
||||
.mc-input-half { width:48%; }
|
||||
.mc-row { display:flex; gap:4%; margin-bottom:16px; }
|
||||
.mc-icon-picker { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.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; }
|
||||
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
|
||||
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; }
|
||||
.mc-radio-label { display:inline-flex; align-items:center; gap:4px; margin-right:16px; font-size:13px; color:#666; cursor:pointer; }
|
||||
.mc-radio-label input { margin:0; }
|
||||
.view-toggle { display:inline-flex; align-items:center; gap:0; margin-right:8px; border:1px solid #dcdfe6; border-radius:4px; overflow:hidden; }
|
||||
.vt-label { padding:5px 12px; font-size:12px; cursor:pointer; color:#606266; background:#fff; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
|
||||
.vt-label input { display:none; }
|
||||
.vt-label.active { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.vt-label:not(:last-child) { border-right:1px solid #dcdfe6; }
|
||||
.vt-label:hover:not(.active) { color:#409eff; }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>战略地图</h3>
|
||||
<div>
|
||||
<button class="map-btn map-btn-danger" :disabled="selectedIds.length === 0" @click="batchDelete">批量删除({{ selectedIds.length }})</button>
|
||||
<el-button size="small" type="danger" :disabled="selectedIds.length === 0" @click="batchDelete">批量删除({{ selectedIds.length }})</el-button>
|
||||
<el-button type="primary" @click="showForm=true;form={}">新建地图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="map-form-dialog">
|
||||
<div class="map-form-header">
|
||||
<span>{{ editingMap ? '编辑地图' : '新建战略地图' }}</span>
|
||||
<button class="map-form-close" @click="showForm=false">×</button>
|
||||
<el-button size="small" text @click="showForm=false">×</el-button>
|
||||
</div>
|
||||
<div class="map-form-body">
|
||||
<div class="map-form-field">
|
||||
@@ -50,8 +50,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-form-footer">
|
||||
<button class="map-btn" @click="showForm=false">取消</button>
|
||||
<button class="map-btn map-btn-primary" @click="saveMap">保存</button>
|
||||
<el-button size="small" @click="showForm=false">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="saveMap">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user