Files
cma-management/frontend/src/views/MapCanvas.vue.bak
T

980 lines
35 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
</div>
<el-button type="primary" @click="saveCanvas">保存</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>
</div>
</div>
<!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar">
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 空状态提示 -->
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
<template #image>
<svg viewBox="0 0 120 120" width="120" height="120">
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
</svg>
</template>
</el-empty>
</div>
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
<div class="canvas-body" ref="bodyRef">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div
v-for="(cfg, idx) in orderedLayerConfigs"
:key="cfg.key"
:ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper"
:style="{ borderLeft: '4px solid ' + cfg.color }"
>
<StrategyLayer
:ref="el => setLayerCompRef(cfg.key, el)"
:layer-key="cfg.key"
:label="cfg.label"
:icon="cfg.icon"
:color="cfg.color"
:objectives="getLayerNodes(cfg.key)"
:linking-from-key="linkingFrom?.key ?? null"
:drag-connect-target="dragConnectTarget"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
:get-node-level="getNodeLevel"
:get-node-progress="getNodeProgress"
@add-objective="(k: string) => openAddDialog(k)"
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
@start-link="(p: any) => { const ps = p.key.split('-'); startLink(ps[0], parseInt(ps[ps.length-1]), p.obj) }"
@link-drag-start="(e: MouseEvent, dk: string, oi: number, obj: any) => onLinkDragStart(e, dk, oi, obj)"
@kpi-click="(code: string) => goKPI(code)"
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
@reorder="(dk: string) => onLayerReorder(dk)"
/>
<!-- 跨层箭头指示器(本层→下层) -->
<div v-if="idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
<svg width="24" height="32" viewBox="0 0 24 32">
<line x1="12" y1="0" x2="12" y2="24" stroke="#909399" stroke-width="2" stroke-dasharray="4,3" />
<polygon points="4,22 12,30 20,22" fill="#909399" />
</svg>
</div>
</div>
</div><!-- end canvas-body -->
</div><!-- end canvas-scroll-wrap -->
<!-- SVG 连线层 (覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines
ref="connectionLinesRef"
:layer-refs="layerRefs"
:node-refs="nodeRefs"
:layer-keys="layerKeysOrdered"
:connections="connections"
:temp-line="tempLine"
:container-key="recalcTrigger"
@select-connection="onSelectConnection"
@delete-connection="onDeleteConnection"
/>
<!-- 节点编辑弹窗 -->
<NodeEditDialog
:visible="showNodeDialog"
:node-data="editingNodeData"
:layer-key="editingLayerKey"
@update:visible="showNodeDialog = $event"
@save="onNodeSave"
/>
<!-- 版本历史弹窗 -->
<div v-if="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>
</div>
<!-- P1-3: 关联知识点 -->
<div style="margin-top:12px;">
<KnowledgePanel related-page="/maps" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { mapApi, kpiApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index"
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
import NodeEditDialog from "../components/strategy-map/NodeEditDialog.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
// ── 四层泳道配置 ──
const layerKeysOrdered = [...LAYER_KEYS]
const orderedLayerConfigs = getLayerList()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const currentMap = ref<any>(null)
const allKpis = ref<any[]>([])
const kpiNameMap = reactive<Record<string, string>>({})
// 维度数据(对应backend的dimensions
const dimensions = reactive<any[]>([])
const loading = ref(true)
// 连线
const connections = ref<any[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
const dragConnectTarget = ref<string | null>(null)
const selectedConnIdx = ref<number | null>(null)
// 缩放
const zoomLevel = ref(1)
const scrollWrapRef = ref<HTMLElement | null>(null)
// 节点状态(红黄绿灯)
const objectiveLevels = reactive<Record<string, string>>({})
interface KpiProgressItem {
ratio: number
level: string
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
}
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
// 弹窗
const showNodeDialog = ref(false)
const editingNodeData = ref<any>(null)
const editingLayerKey = ref('')
const showVersions = ref(false)
const versions = ref<any[]>([])
// DOM引用
const bodyRef = ref<HTMLElement | null>(null)
const layerRefs: Record<string, HTMLElement> = {}
const nodeRefs: Record<string, HTMLElement> = {}
const connectionLinesRef = ref<any>(null)
const recalcTrigger = ref(0)
// 泳道组件实例引用(用于汇聚各层内部的nodeRefs给ConnectionLines
const layerCompRefs: Record<string, any> = {}
function setLayerCompRef(key: string, el: any) {
if (el) layerCompRefs[key] = el
}
/** 从四个泳道组件汇聚所有节点DOM引用用于连线渲染,同步到nodeRefs */
function collectAllNodeRefs(): Record<string, any> {
void (0) // keep as side-effect function
// 清空旧refs
Object.keys(nodeRefs).forEach(k => delete (nodeRefs as any)[k])
// 从各泳道组件汇聚
for (const key of layerKeysOrdered) {
const comp = layerCompRefs[key]
if (comp?.nodeRefs) {
Object.assign(nodeRefs, comp.nodeRefs)
}
}
return nodeRefs
}
// 拖拽引用(由 vuedraggable 接管,内部不维护)
// KPI详情浮层
const showKpiDetail = ref(false)
const kpiDetailObjName = ref("")
const kpiDetailDimKey = ref("")
const kpiDetailObjIdx = ref(-1)
const kpiDetailList = ref<any[]>([])
// 行动方案弹窗
const showPlanPopup = ref(false)
const planPopupObjName = ref("")
const planPopupDimKey = ref("")
const planPopupObjIdx = ref(-1)
const planPopupList = ref<any[]>([])
// ── 图标映射(从旧MapCanvas复用) ──
const iconMap: Record<string, string> = {
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
handshake: "Handshake", medal: "Trophy",
}
// ── 辅助方法 ──
function getLayerNodes(key: string): any[] {
const dim = dimensions.find((d: any) => d.key === key)
return dim?.objectives || []
}
function setLayerRef(key: string, el: any) {
if (el) layerRefs[key] = el
}
function getNodeLevel(key: string): string {
return objectiveLevels[key] || 'gray'
}
function getNodeProgress(key: string): any {
return kpiProgressMap[key] || null
}
/** 确保dimensions中存在所有4层 */
function ensureFourLayers() {
for (const cfg of orderedLayerConfigs) {
if (!dimensions.find((d: any) => d.key === cfg.key)) {
dimensions.push({
key: cfg.key,
name: cfg.label,
label: cfg.label,
icon: cfg.icon,
color: cfg.color,
objectives: [],
})
}
}
}
// ── 缩放 ──
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 loadCanvas() {
if (!selectedMap.value) return
dimensions.splice(0, dimensions.length)
connections.value = []
linkingFrom.value = null
selectedConnIdx.value = null
loading.value = true
api.get("/maps").then((r: any) => {
const data = r.data || []
const map = data.find((m: any) => m.id === selectedMap.value)
currentMap.value = map
if (map?.dimensions) {
for (const dim of map.dimensions) {
dimensions.push({
key: dim.key,
label: dim.name || dim.label,
icon: dim.icon || '📋',
color: dim.color || LAYER_CONFIG[dim.key]?.color || '#409eff',
objectives: dim.objectives || [],
})
}
}
// 确保四层都存在
ensureFourLayers()
connections.value = map?.canvas_data?.connections || []
loading.value = false
nextTick(() => {
triggerRecalc()
loadObjectiveLevels()
})
}).catch(() => {
ensureFourLayers()
loading.value = false
})
}
// ── 触发连线重算 ──
function triggerRecalc() {
recalcTrigger.value++
collectAllNodeRefs()
nextTick(() => {
connectionLinesRef.value?.recalcAll()
})
}
// ── 连线交互 ──
function startLink(dimKey: string, oi: number, obj: any) {
selectedConnIdx.value = null
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
}
function cancelLink() {
linkingFrom.value = null
tempLine.value = null
dragConnectTarget.value = null
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
// 连线模式
if (linkingFrom.value) {
if (linkingFrom.value.key === key) { cancelLink(); return }
completeConnection(linkingFrom.value.key, key)
return
}
// 有KPI则展示详情
if (kpiProgressMap[key]?.kpiList?.length > 0) {
showKpiDetailModal(dimKey, oi, obj)
} else {
openEditDialog(dimKey, oi, obj)
}
}
/**
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
* SortableJS 已通过 :list 引用直接更新了响应式数组,
* 这里只需触发连线重算和自动保存
*/
function onLayerReorder(dimKey: string) {
nextTick(() => {
triggerRecalc()
debouncedSaveCanvas()
})
}
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
}
api.post(`/maps/${selectedMap.value}/connections`, {
from: fromKey, to: toKey,
}).then(() => {
ElMessage.success("连线已添加")
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
cancelLink()
nextTick(() => { triggerRecalc(); saveConnections() })
}).catch((e: any) => {
if (e?.response?.status !== 400) {
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
ElMessage.success("连线已添加(本地)")
} else {
ElMessage.warning(e?.response?.data?.detail || "连线失败")
}
cancelLink()
nextTick(triggerRecalc)
})
}
// 跳转KPI详情
function goKPI(kpiCode: string) {
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
window.location.href = found ? '/kpis/' + found.id : '/kpis'
}
// ── 拖拽连线的鼠标跟踪 ──
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
const startX = e.clientX
const startY = e.clientY
startLink(dimKey, oi, obj)
const onUp = (up: MouseEvent) => {
document.removeEventListener('mouseup', onUp)
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
if (dist >= 5 && linkingFrom.value) {
let targetKey: string | null = null
for (const key of Object.keys(nodeRefs)) {
if (key === linkingFrom.value.key) continue
const el = nodeRefs[key]
if (!el) continue
const rect = el.getBoundingClientRect()
if (up.clientX >= rect.left && up.clientX <= rect.right &&
up.clientY >= rect.top && up.clientY <= rect.bottom) {
targetKey = key; break
}
}
if (targetKey) {
const parts = targetKey.split('-')
const tDim = parts[0]
const tIdx = parseInt(parts[1])
const dim = dimensions.find((d: any) => d.key === tDim)
if (dim && dim.objectives[tIdx]) {
completeConnection(linkingFrom.value.key, targetKey)
}
}
cancelLink()
}
dragConnectTarget.value = null
}
document.addEventListener('mouseup', onUp)
}
function onSelectConnection(idx: number) {
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
}
function onDeleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx]
if (!conn) return
api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to },
}).then(() => {
ElMessage.success("连线已删除")
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
}).catch(() => {
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
})
}
// ── 节点CRUD ──
function openAddDialog(layerKey: string) {
editingLayerKey.value = layerKey
editingNodeData.value = null
showNodeDialog.value = true
}
function openEditDialog(dimKey: string, oi: number, obj: any) {
editingLayerKey.value = dimKey
editingNodeData.value = {
id: obj.id,
name: obj.name,
targetValue: obj.targetValue ?? obj.target_value ?? null,
currentValue: obj.currentValue ?? obj.current_value ?? null,
unit: obj.unit || '%',
owner: obj.owner || '',
isLeading: obj.isLeading ?? obj.is_leading ?? false,
description: obj.description || '',
}
// 保存编辑索引以便更新
editingNodeData.value._dimKey = dimKey
editingNodeData.value._index = oi
showNodeDialog.value = true
}
function onNodeSave(data: any) {
const dim = dimensions.find((d: any) => d.key === data.layer)
if (!dim) return
// 构建节点对象
const node = {
name: data.name,
targetValue: data.targetValue,
currentValue: data.currentValue,
unit: data.unit,
owner: data.owner,
isLeading: data.isLeading,
description: data.description || '',
layer: data.layer,
}
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === data.layer) {
// 编辑已有节点
const idx = editingNodeData.value._index
if (dim.objectives[idx]) {
// 保留原有kpis等额外字段
const extra = {
kpis: dim.objectives[idx].kpis || [],
icon: dim.objectives[idx].icon || 'target',
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
}
} else {
// 新增节点
dim.objectives.push({
...node,
kpis: [],
icon: 'target',
})
ElMessage.success("节点已添加")
}
editingNodeData.value = null
debouncedSaveCanvas()
nextTick(triggerRecalc)
}
async function deleteNode(dimKey: string, oi: number) {
try {
await ElMessageBox.confirm("确定要删除这个节点吗?", "确认删除")
const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) {
const key = `${dimKey}-${oi}`
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
dim.objectives.splice(oi, 1)
ElMessage.success("节点已删除")
nextTick(() => { triggerRecalc(); debouncedSaveCanvas() })
}
} catch { /* cancel */ }
}
// ── 保存 ──
let saveTimer: ReturnType<typeof setTimeout> | null = null
let isSaving = false
function debouncedSaveCanvas() {
if (isSaving) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveCanvas() }, 500)
}
async function saveCanvas() {
if (!selectedMap.value) return
if (isSaving) return
isSaving = true
const mapData = dimensions.map((d: any) => ({
key: d.key, name: d.label || d.name, icon: d.icon, color: d.color,
objectives: d.objectives,
}))
try {
await mapApi.update(selectedMap.value, {
dimensions: mapData,
canvas_data: { connections: connections.value },
version_num: currentMap.value?.version_num,
})
ElMessage.success("已保存")
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("地图已被修改,刷新后重试")
} else {
console.error("saveCanvas error:", e)
}
} finally {
isSaving = false
}
}
async function saveConnections() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, {
canvas_data: { connections: connections.value },
})
} catch { /* silent */ }
}
// ── 发布/版本 ──
async function publishMap() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, { status: "published" })
ElMessage.success("已发布(自动创建版本快照)")
currentMap.value.status = "published"
loadCanvas()
} catch (e) {
ElMessage.error("发布失败")
}
}
async function loadVersions() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
versions.value = r.data || []
} catch { versions.value = [] }
}
async function rollback(verId: number) {
try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
ElMessage.success("回滚成功,刷新页面")
window.location.reload()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败")
}
}
// ── 加载目标状态 ──
async function loadObjectiveLevels() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
const dims = r.dimensions || []
for (const dim of dims) {
const dimKey = dim.key
const localDim = dimensions.find((d: any) => d.key === dimKey)
if (!localDim) continue
for (let i = 0; i < localDim.objectives.length; i++) {
const key = `${dimKey}-${i}`
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
objectiveLevels[key] = 'gray'
}
dim.objectives.forEach((obj: any) => {
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
if (localIdx >= 0) {
const key = `${dimKey}-${localIdx}`
objectiveLevels[key] = obj.level || 'gray'
const kpis = obj.kpis || []
const kpiList: any[] = []
let totalRatio = 0
let count = 0
for (const k of kpis) {
kpiList.push({
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
})
if (k.actual_value != null && k.target_value) {
totalRatio += k.actual_value / k.target_value; count++
}
}
if (kpiList.length > 0) {
kpiProgressMap[key] = {
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
level: obj.level || 'gray', kpiList,
planSummary: obj.action_plan_summary || null,
}
} else if (obj.action_plan_summary?.total > 0) {
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
}
}
})
}
} catch { /* silent */ }
}
// ── KPI详情浮层 ──
function showKpiDetailModal(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
kpiDetailObjName.value = obj.name
kpiDetailDimKey.value = dimKey
kpiDetailObjIdx.value = oi
kpiDetailList.value = kpiProgressMap[key]?.kpiList || []
showKpiDetail.value = true
}
function editKpiDetailObj() {
showKpiDetail.value = false
const dim = dimensions.find((d: any) => d.key === kpiDetailDimKey.value)
if (dim && dim.objectives[kpiDetailObjIdx.value]) {
openEditDialog(kpiDetailDimKey.value, kpiDetailObjIdx.value, dim.objectives[kpiDetailObjIdx.value])
}
}
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
planPopupDimKey.value = dimKey
planPopupObjIdx.value = oi
planPopupList.value = []
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
const dims = r.dimensions || []
const allPlans = r.action_plans || []
for (const dim of dims) {
if (dim.key !== dimKey) continue
for (const robj of dim.objectives || []) {
if (robj.name !== obj.name) continue
const objKpiIds = (robj.kpis || []).map((k: any) => k.kpi_id)
planPopupList.value = allPlans.filter((p: any) => objKpiIds.includes(p.kpi_id))
break
}
break
}
} catch { /* silent */ }
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}`
}
function goAlignment() {
const url = currentMap.value ? `/alignment?map_id=${currentMap.value.id}` : '/alignment'
window.location.href = url
}
onMounted(async () => {
const r: any = await mapApi.list()
maps.value = r.data || []
if (maps.value.length > 0) {
selectedMap.value = maps.value[0].id
loadCanvas()
} else {
ensureFourLayers()
loading.value = false
}
const k: any = await kpiApi.list({ page_size: 100 })
allKpis.value = k.data || []
for (const kpi of allKpis.value) {
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
}
window.addEventListener('resize', triggerRecalc)
})
onUnmounted(() => {
window.removeEventListener('resize', triggerRecalc)
if (saveTimer) clearTimeout(saveTimer)
})
</script>
<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;
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
}
.empty-state {
flex: 1; display: flex; align-items: center; justify-content: center;
}
/* ── 画布滚动容器 ── */
.canvas-scroll-wrap {
flex: 1; min-height: 0; overflow: auto;
position: relative; width: 100%;
}
.canvas-body {
display: flex; flex-direction: column; gap: 0;
position: relative; isolation: isolate;
width: 100%; padding: 8px 0;
}
/* ── 泳道外层包装 ── */
.layer-swimlane-wrapper {
width: 100%;
margin-bottom: 4px;
}
/* ── 跨层箭头指示器 ── */
.cross-layer-arrow {
display: flex;
justify-content: center;
align-items: center;
height: 32px;
flex-shrink: 0;
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; }
</style>