init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 画布主体 -->
|
||||
<div class="canvas-body" ref="bodyRef">
|
||||
<!-- SVG 连线层 -->
|
||||
<svg class="connection-svg" ref="svgRef">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
|
||||
</marker>
|
||||
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line v-for="(line, idx) in connectionLines" :key="idx"
|
||||
:x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
|
||||
:class="['conn-line', { 'conn-selected': selectedConnIdx === idx }]"
|
||||
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
|
||||
marker-end="url(#arrowhead)"
|
||||
:stroke="selectedConnIdx === idx ? '#f56c6c' : '#409eff'"
|
||||
:stroke-width="selectedConnIdx === idx ? 3 : 2"
|
||||
@click.stop="selectConnection(idx)"
|
||||
/>
|
||||
<!-- 删除按钮(选中连线时) -->
|
||||
<g v-if="selectedConnIdx !== null && connectionLines[selectedConnIdx]">
|
||||
<circle
|
||||
:cx="connectionLines[selectedConnIdx].mx"
|
||||
:cy="connectionLines[selectedConnIdx].my"
|
||||
r="10" fill="#f56c6c" class="del-btn-circle"
|
||||
@click.stop="deleteConnection(selectedConnIdx)"
|
||||
/>
|
||||
<text
|
||||
:x="connectionLines[selectedConnIdx].mx"
|
||||
:y="connectionLines[selectedConnIdx].my + 4"
|
||||
text-anchor="middle" fill="#fff" font-size="12" font-weight="bold"
|
||||
class="del-btn-text"
|
||||
@click.stop="deleteConnection(selectedConnIdx)"
|
||||
>✕</text>
|
||||
</g>
|
||||
<!-- 绘制中的临时线 -->
|
||||
<line v-if="tempLine"
|
||||
:x1="tempLine.x1" :y1="tempLine.y1"
|
||||
:x2="tempLine.x2" :y2="tempLine.y2"
|
||||
stroke="#e6a23c" stroke-width="2" stroke-dasharray="6,3"
|
||||
marker-end="url(#arrowhead-warn)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 四维度列 -->
|
||||
<div class="dimension-col" v-for="(dim, di) in dimensions" :key="dim.key"
|
||||
:ref="el => setDimRef(dim.key, el)"
|
||||
:style="{ borderColor: dim.color }">
|
||||
<div class="dim-header" :style="{ background: dim.color }"
|
||||
@dblclick="startEditDim(di)"
|
||||
:title="'双击编辑维度'">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
<span v-if="editingDimIdx !== di" class="dim-name">{{ dim.label }}</span>
|
||||
<el-input v-else v-model="dim.label" size="small" style="width:100px;"
|
||||
@blur="editingDimIdx = -1"
|
||||
@keyup.enter="editingDimIdx = -1" />
|
||||
<!-- 维度操作按钮 -->
|
||||
<el-dropdown trigger="click" @command="(cmd:string) => dimAction(cmd, di)">
|
||||
<el-button class="dim-more-btn" size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
|
||||
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
|
||||
<el-dropdown-item v-if="dimensions.length > 1" command="delete" divided>删除此维度</el-dropdown-item>
|
||||
<el-dropdown-item command="add">左侧添加维度</el-dropdown-item>
|
||||
<el-dropdown-item command="addRight">右侧添加维度</el-dropdown-item>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="dim-body">
|
||||
<div
|
||||
v-for="(obj, oi) in dim.objectives" :key="oi"
|
||||
:ref="el => setObjRef(`${dim.key}-${oi}`, el)"
|
||||
class="objective-card"
|
||||
:class="{
|
||||
'linking-source': linkingFrom?.key === `${dim.key}-${oi}`,
|
||||
'linking-target': linkingFrom && linkingFrom?.key !== `${dim.key}-${oi}`,
|
||||
'obj-level-red': objectiveLevels[`${dim.key}-${oi}`] === 'red',
|
||||
'obj-level-yellow': objectiveLevels[`${dim.key}-${oi}`] === 'yellow',
|
||||
'obj-level-green': objectiveLevels[`${dim.key}-${oi}`] === 'green',
|
||||
}"
|
||||
@click="onObjClick(dim.key, oi, obj)"
|
||||
>
|
||||
<div class="obj-title">
|
||||
<span class="obj-level-dot" :class="'dot-' + (objectiveLevels[`${dim.key}-${oi}`] || 'gray')"></span>
|
||||
<el-icon v-if="obj.icon" style="margin-right:4px;"><component :is="iconMap[obj.icon]" /></el-icon>
|
||||
{{ obj.name }}
|
||||
</div>
|
||||
<div v-if="obj.description" class="obj-desc">{{ obj.description }}</div>
|
||||
<div class="obj-kpis">
|
||||
<el-tag v-for="kpi in (obj.kpis||[])" :key="kpi" size="small"
|
||||
style="margin:2px;cursor:pointer;"
|
||||
@click.stop="goKPI(kpi)">
|
||||
{{ kpi }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="obj-actions">
|
||||
<el-button text size="small" @click.stop="editObjective(dim.key, oi, obj)" title="编辑目标">✏️</el-button>
|
||||
<el-button text size="small" @click.stop="deleteObjective(dim.key, oi)" title="删除目标" style="color:#f56c6c;">🗑️</el-button>
|
||||
<span class="link-btn" @click.stop="startLink(dim.key, oi, obj)" title="从此目标创建因果连线">↗</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="addObjective(dim.key)">
|
||||
+ 添加目标
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑目标弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="editingIndex >= 0 ? '编辑目标' : '添加目标'" width="500">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="目标名称"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" rows="3" /></el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-select v-model="editForm.icon" style="width:100%;">
|
||||
<el-option v-for="(ico, key) in iconOptions" :key="key" :label="ico" :value="key">
|
||||
<el-icon style="margin-right:6px;"><component :is="iconMap[key]" /></el-icon>{{ ico }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联KPI">
|
||||
<div class="kpi-select-wrap">
|
||||
<el-select v-model="editForm.kpis" multiple filterable style="flex:1;" :max="3"
|
||||
popper-class="dialog-select-popper">
|
||||
<el-option v-for="k in allKpis" :key="k.id" :label="k.kpi_name" :value="k.kpi_code" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="$router.push('/kpis')" style="margin-left:4px;">管理KPI</el-button>
|
||||
</div>
|
||||
<span v-if="editForm.kpis.length >= 3" style="color:#e6a23c;font-size:12px;">每个目标最多绑定3个KPI</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveObjective">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 版本历史 -->
|
||||
<el-dialog v-model="showVersions" title="版本历史" width="600">
|
||||
<el-table :data="versions" style="width:100%">
|
||||
<el-table-column prop="comment" label="说明" min-width="200" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" @click="rollback(row.id)">回滚</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { MoreFilled } from "@element-plus/icons-vue"
|
||||
import { mapApi, kpiApi } from "../api/index"
|
||||
import api from "../api/index"
|
||||
|
||||
// ── 维度配置(动态从后端加载) ──
|
||||
const DEFAULT_DIMENSIONS = [
|
||||
{ key: "finance", label: "财务维度", icon: "💰", color: "#409eff", objectives: [] as any[] },
|
||||
{ key: "customer", label: "客户维度", icon: "🤝", color: "#67c23a", objectives: [] as any[] },
|
||||
{ key: "process", label: "内部流程", icon: "⚙️", color: "#e6a23c", objectives: [] as any[] },
|
||||
{ key: "learning", label: "学习成长", icon: "📚", color: "#f56c6c", objectives: [] as any[] },
|
||||
]
|
||||
const dimensions = reactive<any[]>([])
|
||||
const editingDimIdx = ref(-1)
|
||||
|
||||
// 用默认值初始化
|
||||
DEFAULT_DIMENSIONS.forEach(d => dimensions.push({ ...d, objectives: [] }))
|
||||
|
||||
// ── 维度操作方法 ──
|
||||
function startEditDim(idx: number) {
|
||||
editingDimIdx.value = idx
|
||||
}
|
||||
|
||||
function dimAction(cmd: string, idx: number) {
|
||||
switch (cmd) {
|
||||
case "rename":
|
||||
editingDimIdx.value = idx
|
||||
break
|
||||
case "color": {
|
||||
const colors = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399", "#9b59b6", "#1abc9c", "#e74c3c"]
|
||||
const cur = dimensions[idx].color
|
||||
const nextIdx = (colors.indexOf(cur) + 1) % colors.length
|
||||
dimensions[idx].color = colors[nextIdx]
|
||||
break
|
||||
}
|
||||
case "icon": {
|
||||
const icons = ["💰", "🤝", "⚙️", "📚", "📊", "🎯", "🛡️", "⭐", "📈", "🔬"]
|
||||
const cur = dimensions[idx].icon
|
||||
const nextIdx = (icons.indexOf(cur) + 1) % icons.length
|
||||
dimensions[idx].icon = icons[nextIdx]
|
||||
break
|
||||
}
|
||||
case "delete":
|
||||
dimensions.splice(idx, 1)
|
||||
break
|
||||
case "add": {
|
||||
const key = "dim_" + Date.now()
|
||||
dimensions.splice(idx, 0, {
|
||||
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
|
||||
})
|
||||
editingDimIdx.value = idx
|
||||
break
|
||||
}
|
||||
case "addRight": {
|
||||
const key = "dim_" + Date.now()
|
||||
dimensions.splice(idx + 1, 0, {
|
||||
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
|
||||
})
|
||||
editingDimIdx.value = idx + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const iconOptions: Record<string, string> = {
|
||||
target: "目标", star: "星级", rocket: "火箭", chart: "图表", team: "团队",
|
||||
light: "灯泡", shield: "盾牌", gear: "齿轮", handshake: "握手", medal: "奖牌",
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const currentMap = ref<any>(null)
|
||||
const allKpis = ref<any[]>([])
|
||||
const showDialog = ref(false)
|
||||
const editingDim = ref("")
|
||||
const editingIndex = ref(-1)
|
||||
const editForm = reactive({ name: "", description: "", icon: "target", kpis: [] as string[] })
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
const connections = ref<any[]>([]) // [{from, to, style}]
|
||||
// 目标红黄绿灯状态
|
||||
const objectiveLevels = reactive<Record<string, string>>({})
|
||||
|
||||
// 连线交互
|
||||
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
const selectedConnIdx = ref<number | null>(null)
|
||||
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
|
||||
|
||||
// DOM 引用
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const dimRefs: Record<string, HTMLElement> = {}
|
||||
const objRefs: Record<string, HTMLElement> = {}
|
||||
|
||||
function setDimRef(key: string, el: any) { if (el) dimRefs[key] = el }
|
||||
function setObjRef(key: string, el: any) { if (el) objRefs[key] = el }
|
||||
|
||||
// 计算连线在 SVG 中的坐标
|
||||
const connectionLines = ref<any[]>([])
|
||||
|
||||
function recalcLines() {
|
||||
if (!svgRef.value || !bodyRef.value) { connectionLines.value = []; return }
|
||||
const svgRect = svgRef.value.getBoundingClientRect()
|
||||
const bodyRect = bodyRef.value.getBoundingClientRect()
|
||||
|
||||
connectionLines.value = connections.value.map(c => {
|
||||
const fromEl = objRefs[c.from]
|
||||
const toEl = objRefs[c.to]
|
||||
if (!fromEl || !toEl) return { x1: 0, y1: 0, x2: 0, y2: 0, mx: 0, my: 0, fromColor: '#999', toColor: '#999' }
|
||||
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
const tr = toEl.getBoundingClientRect()
|
||||
const dimKey = c.from.split('-')[0]
|
||||
const toDimKey = c.to.split('-')[0]
|
||||
const fromDim = dimensions.find(d => d.key === dimKey)
|
||||
const toDim = dimensions.find(d => d.key === toDimKey)
|
||||
|
||||
// 从右侧中点 → 左侧中点
|
||||
const x1 = fr.right - bodyRect.left
|
||||
const y1 = fr.top + fr.height / 2 - bodyRect.top
|
||||
const x2 = tr.left - bodyRect.left
|
||||
const y2 = tr.top + tr.height / 2 - bodyRect.top
|
||||
|
||||
return {
|
||||
x1, y1, x2, y2,
|
||||
mx: (x1 + x2) / 2,
|
||||
my: (y1 + y2) / 2,
|
||||
fromColor: fromDim?.color || '#999',
|
||||
toColor: toDim?.color || '#999',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// ── 核心操作 ──
|
||||
function loadCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
for (const dim of dimensions) { dim.objectives = [] }
|
||||
connections.value = []
|
||||
linkingFrom.value = null
|
||||
selectedConnIdx.value = null
|
||||
|
||||
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) {
|
||||
const target = dimensions.find((d: any) => d.key === dim.key || d.label === dim.name)
|
||||
if (target) {
|
||||
target.objectives = dim.objectives || []
|
||||
target.icon = dim.icon || target.icon
|
||||
target.color = dim.color || target.color
|
||||
}
|
||||
}
|
||||
}
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
nextTick(recalcLines)
|
||||
|
||||
// 加载目标状态(红黄绿灯)
|
||||
loadObjectiveLevels()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 连线交互 ──
|
||||
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
|
||||
}
|
||||
|
||||
function onObjClick(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
|
||||
// 如果正在连线模式
|
||||
if (linkingFrom.value) {
|
||||
if (linkingFrom.value.key === key) { cancelLink(); return }
|
||||
|
||||
// 校验规则
|
||||
const fromDim = linkingFrom.value.key.split('-')[0]
|
||||
const toDim = key.split('-')[0]
|
||||
if (fromDim === toDim) { ElMessage.warning("同维度内不能连线"); return }
|
||||
|
||||
// 检查重复
|
||||
if (connections.value.some((c: any) => c.from === linkingFrom.value.key && c.to === key)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
}
|
||||
|
||||
// 调用后端API
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: linkingFrom.value.key,
|
||||
to: key,
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已添加")
|
||||
// 本地追加并保存全量到后端
|
||||
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
|
||||
cancelLink()
|
||||
nextTick(() => { recalcLines(); saveConnections() })
|
||||
}).catch((e: any) => {
|
||||
// 后端失败时本地加(降级)
|
||||
if (e?.response?.status !== 400) {
|
||||
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
|
||||
ElMessage.success("连线已添加(本地)")
|
||||
} else {
|
||||
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
||||
}
|
||||
cancelLink()
|
||||
nextTick(recalcLines)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 非连线模式:编辑目标
|
||||
editObjective(dimKey, oi, obj)
|
||||
}
|
||||
|
||||
// 跳转KPI详情
|
||||
function goKPI(kpiCode: string) {
|
||||
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
|
||||
if (found) {
|
||||
window.location.href = '/kpis/' + found.id
|
||||
} else {
|
||||
window.location.href = '/kpis'
|
||||
}
|
||||
}
|
||||
|
||||
// 删除目标
|
||||
async function deleteObjective(dimKey: string, oi: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除这个目标吗?", "确认删除")
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (dim) {
|
||||
dim.objectives.splice(oi, 1)
|
||||
ElMessage.success("目标已删除")
|
||||
}
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
function selectConnection(idx: number) {
|
||||
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function deleteConnection(idx: number) {
|
||||
if (selectedConnIdx.value !== idx) return
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
|
||||
// 通过 from/to 标识删除,不用索引
|
||||
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(recalcLines)
|
||||
}).catch(() => {
|
||||
// 后端失败时本地直接删
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(recalcLines)
|
||||
})
|
||||
}
|
||||
|
||||
// 鼠标移动跟踪(临时连线)
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!linkingFrom.value || !bodyRef.value) { tempLine.value = null; return }
|
||||
const fromEl = objRefs[linkingFrom.value.key]
|
||||
if (!fromEl) return
|
||||
const bodyRect = bodyRef.value.getBoundingClientRect()
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
tempLine.value = {
|
||||
x1: fr.right - bodyRect.left,
|
||||
y1: fr.top + fr.height / 2 - bodyRect.top,
|
||||
x2: e.clientX - bodyRect.left,
|
||||
y2: e.clientY - bodyRect.top,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 目标 CRUD ──
|
||||
function addObjective(dimKey: string) {
|
||||
editingDim.value = dimKey
|
||||
editingIndex.value = -1
|
||||
editForm.name = ""
|
||||
editForm.description = ""
|
||||
editForm.icon = "target"
|
||||
editForm.kpis = []
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function editObjective(dimKey: string, idx: number, obj: any) {
|
||||
editingDim.value = dimKey
|
||||
editingIndex.value = idx
|
||||
editForm.name = obj.name
|
||||
editForm.description = obj.description || ""
|
||||
editForm.icon = obj.icon || "target"
|
||||
editForm.kpis = obj.kpis || []
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function saveObjective() {
|
||||
if (!editForm.name) { ElMessage.warning("请输入目标名称"); return }
|
||||
const dim = dimensions.find((d: any) => d.key === editingDim.value)
|
||||
if (!dim) return
|
||||
if (editingIndex.value >= 0) {
|
||||
dim.objectives[editingIndex.value] = { name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] }
|
||||
} else {
|
||||
dim.objectives.push({ name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] })
|
||||
}
|
||||
showDialog.value = false
|
||||
saveCanvas()
|
||||
// 强制刷新目标卡片DOM引用
|
||||
nextTick(() => {
|
||||
recalcLines()
|
||||
})
|
||||
}
|
||||
|
||||
async function saveConnections() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function saveCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
const mapData = dimensions.map((d: any) => ({
|
||||
key: d.key, name: d.label, icon: d.icon, color: d.color, objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
// 保存后刷新objRefs
|
||||
nextTick(() => {
|
||||
for (const dim of dimensions) {
|
||||
for (let i = 0; i < dim.objectives.length; i++) {
|
||||
const key = `${dim.key}-${i}`
|
||||
if (!objRefs[key]) {
|
||||
// 新添加的目标可能还没被ref捕获,再等一帧
|
||||
setTimeout(() => { recalcLines() }, 100)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
recalcLines()
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error("saveCanvas error:", e)
|
||||
ElMessage.error("保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布/版本 ──
|
||||
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 (e) {
|
||||
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]
|
||||
}
|
||||
// 从review接口的维度数据中提取每个目标的状态
|
||||
const dims = r.dimensions || []
|
||||
for (const dim of dims) {
|
||||
const dimKey = dim.key
|
||||
dim.objectives.forEach((obj: any, idx: number) => {
|
||||
const key = `${dimKey}-${idx}`
|
||||
objectiveLevels[key] = obj.level || 'gray'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
maps.value = r.data || []
|
||||
if (maps.value.length > 0) {
|
||||
selectedMap.value = maps.value[0].id
|
||||
loadCanvas()
|
||||
}
|
||||
const k: any = await kpiApi.list()
|
||||
allKpis.value = k.data || []
|
||||
|
||||
// 监听鼠标移动画临时线
|
||||
document.addEventListener("mousemove", onMouseMove)
|
||||
|
||||
// 监听窗口/列大小变化重新计算连线
|
||||
resizeObserver = new ResizeObserver(() => nextTick(recalcLines))
|
||||
if (bodyRef.value) resizeObserver.observe(bodyRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
})
|
||||
</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; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
display: flex; gap: 12px; flex: 1; min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* SVG 连线层 */
|
||||
.connection-svg {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 5; pointer-events: none;
|
||||
}
|
||||
.conn-line {
|
||||
cursor: pointer; transition: stroke .2s;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 3; }
|
||||
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
|
||||
.del-btn-circle { cursor: pointer; pointer-events: all; }
|
||||
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
|
||||
|
||||
/* 四列 */
|
||||
.dimension-col {
|
||||
flex: 1; min-width: 0;
|
||||
border: 2px solid #e8e8e8; border-radius: 8px;
|
||||
display: flex; flex-direction: column; background: #fafafa;
|
||||
overflow-y: auto; position: relative; z-index: 1;
|
||||
}
|
||||
.dim-header {
|
||||
padding: 10px; color: #fff; border-radius: 6px 6px 0 0;
|
||||
text-align: center; font-weight: bold; display: flex;
|
||||
align-items: center; justify-content: center; gap: 6px; font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dim-body { padding: 10px; flex: 1; }
|
||||
|
||||
.objective-card {
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
padding: 10px; margin-bottom: 8px; cursor: pointer;
|
||||
transition: all 0.2s; position: relative;
|
||||
}
|
||||
.objective-card:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
|
||||
.objective-card.linking-source {
|
||||
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
|
||||
}
|
||||
.objective-card.linking-target:hover {
|
||||
border-color: #67c23a; box-shadow: 0 0 0 2px rgba(103,194,58,0.3);
|
||||
}
|
||||
|
||||
.obj-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
|
||||
.obj-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||
.obj-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
|
||||
/* 操作按钮组 */
|
||||
.obj-actions { display: flex; align-items: center; gap: 2px; margin-top: 6px; justify-content: flex-end; }
|
||||
.obj-actions .el-button { padding: 2px 4px !important; font-size: 12px; min-height: auto; }
|
||||
.obj-actions .link-btn {
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: #ecf5ff; color: #409eff; border-radius: 50%;
|
||||
font-size: 14px; font-weight: bold; cursor: pointer; transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.obj-actions .link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
|
||||
|
||||
/* 连线入口按钮(旧,保留兼容) */
|
||||
.link-btn {
|
||||
position: absolute; bottom: 4px; right: 6px;
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: #ecf5ff; color: #409eff; border-radius: 50%;
|
||||
font-size: 14px; font-weight: bold; cursor: pointer; opacity: 0;
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.objective-card:hover .link-btn { opacity: 1; }
|
||||
.link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
|
||||
|
||||
/* 目标红黄绿灯指示器 */
|
||||
.obj-level-dot {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
margin-right: 4px; flex-shrink: 0;
|
||||
}
|
||||
.dot-green { background: #67c23a; }
|
||||
.dot-yellow { background: #e6a23c; }
|
||||
.dot-red { background: #f56c6c; }
|
||||
.dot-gray { background: #dcdfe6; }
|
||||
.obj-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
|
||||
.obj-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
|
||||
.obj-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
|
||||
|
||||
/* 维度列头部操作按钮 */
|
||||
.dim-more-btn { color: rgba(255,255,255,0.7) !important; margin-left: auto; padding: 2px !important; }
|
||||
.dim-more-btn:hover { color: #fff !important; }
|
||||
.dim-header { position: relative; }
|
||||
|
||||
/* KPI选择区 */
|
||||
.kpi-select-wrap { display: flex; align-items: center; width: 100%; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user