feat: 战略地图收尾 — 自动保存+导出+表迁移+因果链推荐
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
-7
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import StrategicMap, OperationLog
|
from app.models import StrategicMap, OperationLog, MapObjective
|
||||||
import json
|
import json
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||||
@@ -62,7 +62,7 @@ STRATEGIC_MAP_TEMPLATE = [
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
def list_maps(db: Session = Depends(get_db)):
|
def list_maps(db: Session = Depends(get_db)):
|
||||||
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
||||||
return {"data": [m_to_dict(m) for m in maps]}
|
return {"data": [m_to_dict(m, db) for m in maps]}
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_map(data: dict, db: Session = Depends(get_db)):
|
def create_map(data: dict, db: Session = Depends(get_db)):
|
||||||
@@ -70,7 +70,8 @@ def create_map(data: dict, db: Session = Depends(get_db)):
|
|||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(m)
|
db.refresh(m)
|
||||||
return m_to_dict(m)
|
_sync_map_objectives(m, db)
|
||||||
|
return m_to_dict(m, db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create-with-template")
|
@router.post("/create-with-template")
|
||||||
@@ -86,7 +87,8 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
|||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(m)
|
db.refresh(m)
|
||||||
return m_to_dict(m)
|
_sync_map_objectives(m, db)
|
||||||
|
return m_to_dict(m, db)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{map_id}")
|
@router.put("/{map_id}")
|
||||||
@@ -101,12 +103,14 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
setattr(m, k, v)
|
setattr(m, k, v)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# 同步目标到map_objectives表
|
||||||
|
_sync_map_objectives(m, db)
|
||||||
|
|
||||||
# ├─ 版本管理: draft → published 时自动创建快照
|
# ├─ 版本管理: draft → published 时自动创建快照
|
||||||
if old_status == "draft" and m.status == "published":
|
if old_status == "draft" and m.status == "published":
|
||||||
_auto_snapshot(m, db)
|
_auto_snapshot(m, db)
|
||||||
|
|
||||||
return m_to_dict(m)
|
return m_to_dict(m, db)
|
||||||
|
|
||||||
|
|
||||||
# ── 删除地图 ─────────────────────────────────
|
# ── 删除地图 ─────────────────────────────────
|
||||||
@@ -259,8 +263,52 @@ def _auto_snapshot(m: StrategicMap, db: Session):
|
|||||||
|
|
||||||
# ── 工具函数 ─────────────────────────────────
|
# ── 工具函数 ─────────────────────────────────
|
||||||
|
|
||||||
def m_to_dict(m):
|
def m_to_dict(m, db: Session = None):
|
||||||
return {c.name: getattr(m, c.name) for c in m.__table__.columns}
|
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
|
||||||
|
if db:
|
||||||
|
_merge_map_objectives(m, db)
|
||||||
|
d["dimensions"] = m.dimensions
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_map_objectives(m, db):
|
||||||
|
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
|
||||||
|
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
|
||||||
|
dims = m.dimensions
|
||||||
|
if isinstance(dims, str):
|
||||||
|
dims = json.loads(dims)
|
||||||
|
for dim in dims:
|
||||||
|
for i, obj in enumerate(dim.get("objectives", [])):
|
||||||
|
mo = MapObjective(
|
||||||
|
map_id=m.id,
|
||||||
|
dimension_key=dim.get("key", ""),
|
||||||
|
name=obj.get("name", ""),
|
||||||
|
description=obj.get("description", ""),
|
||||||
|
icon=obj.get("icon", "target"),
|
||||||
|
sort_order=i,
|
||||||
|
)
|
||||||
|
db.add(mo)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_map_objectives(m, db):
|
||||||
|
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
|
||||||
|
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
|
||||||
|
if not objs:
|
||||||
|
return
|
||||||
|
dims = m.dimensions
|
||||||
|
if isinstance(dims, str):
|
||||||
|
dims = json.loads(dims)
|
||||||
|
# 按dimension_key分组
|
||||||
|
from collections import defaultdict
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for o in objs:
|
||||||
|
grouped[o.dimension_key].append(o)
|
||||||
|
for dim in dims:
|
||||||
|
key = dim.get("key", "")
|
||||||
|
if key in grouped:
|
||||||
|
dim["objectives"] = [{"name": o.name, "description": o.description or "", "icon": o.icon or "target"} for o in grouped[key]]
|
||||||
|
m.dimensions = dims
|
||||||
|
|
||||||
|
|
||||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
|||||||
|
"""迁移脚本:将 strategic_maps.dimensions JSON 中的目标同步到 map_objectives 表"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.database import get_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
logger = logging.getLogger("migrate_map_objectives")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_map_to_objectives(conn, map_id: int, dims: list) -> int:
|
||||||
|
"""将单个地图的 dimensions 同步到 map_objectives 表,返回同步的目标数"""
|
||||||
|
# 删除该地图的旧目标
|
||||||
|
conn.execute(
|
||||||
|
text("DELETE FROM map_objectives WHERE map_id = :mid"),
|
||||||
|
{"mid": map_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for dim in dims:
|
||||||
|
dim_key = dim.get("key", "")
|
||||||
|
for idx, obj in enumerate(dim.get("objectives", [])):
|
||||||
|
name = obj.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
text("""INSERT INTO map_objectives
|
||||||
|
(map_id, dimension_key, name, description, icon, sort_order)
|
||||||
|
VALUES (:mid, :dk, :name, :desc, :icon, :sort)"""),
|
||||||
|
{
|
||||||
|
"mid": map_id,
|
||||||
|
"dk": dim_key,
|
||||||
|
"name": name,
|
||||||
|
"desc": obj.get("description", ""),
|
||||||
|
"icon": obj.get("icon", "target"),
|
||||||
|
"sort": idx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# 查询所有战略地图
|
||||||
|
maps = conn.execute(
|
||||||
|
text("SELECT id, title, dimensions FROM strategic_maps ORDER BY id")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_maps = 0
|
||||||
|
total_objectives = 0
|
||||||
|
|
||||||
|
for mid, title, dims_json in maps:
|
||||||
|
if not dims_json:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dims = json.loads(dims_json) if isinstance(dims_json, str) else dims_json
|
||||||
|
if not isinstance(dims, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
count = sync_map_to_objectives(conn, mid, dims)
|
||||||
|
if count > 0:
|
||||||
|
total_maps += 1
|
||||||
|
total_objectives += count
|
||||||
|
logger.info(f" 地图[{mid}] {title}: 同步 {count} 个目标")
|
||||||
|
|
||||||
|
logger.info(f"\n✅ 迁移完成: {total_maps} 个地图, {total_objectives} 个目标已同步")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("=== 开始迁移 map_objectives ===")
|
||||||
|
migrate()
|
||||||
|
logger.info("=== 完成 ===")
|
||||||
Vendored
-51
@@ -13,54 +13,6 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default']
|
BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default']
|
||||||
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default']
|
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
|
||||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
|
||||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
|
||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
|
||||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
|
||||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
|
||||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
|
||||||
ElTree: typeof import('element-plus/es')['ElTree']
|
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
|
||||||
GaugeChart: typeof import('./src/components/charts/GaugeChart.vue')['default']
|
GaugeChart: typeof import('./src/components/charts/GaugeChart.vue')['default']
|
||||||
KnowledgePanel: typeof import('./src/components/KnowledgePanel.vue')['default']
|
KnowledgePanel: typeof import('./src/components/KnowledgePanel.vue')['default']
|
||||||
KPIListView: typeof import('./src/components/KPIListView.vue')['default']
|
KPIListView: typeof import('./src/components/KPIListView.vue')['default']
|
||||||
@@ -74,7 +26,4 @@ declare module 'vue' {
|
|||||||
WaterfallChart: typeof import('./src/components/charts/WaterfallChart.vue')['default']
|
WaterfallChart: typeof import('./src/components/charts/WaterfallChart.vue')['default']
|
||||||
WelcomeGuide: typeof import('./src/components/WelcomeGuide.vue')['default']
|
WelcomeGuide: typeof import('./src/components/WelcomeGuide.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
<input type="radio" v-model="viewDirection" value="causal" /> 🔗 因果
|
<input type="radio" v-model="viewDirection" value="causal" /> 🔗 因果
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<el-button type="primary" @click="saveCanvas">保存</el-button>
|
<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 type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||||
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
|
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
|
||||||
<el-button @click="goAlignment" type="info" plain>
|
<el-button @click="goAlignment" type="info" plain>
|
||||||
@@ -167,6 +169,35 @@
|
|||||||
</div>
|
</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 v-show="showVersions" class="mc-dialog-overlay" @click.self="showVersions=false">
|
||||||
<div class="mc-dialog-box" style="width:620px;">
|
<div class="mc-dialog-box" style="width:620px;">
|
||||||
@@ -935,6 +966,48 @@ async function exportImage() {
|
|||||||
ElMessage.error('导出失败')
|
ElMessage.error('导出失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── KPI因果链推荐 ──
|
||||||
|
const showCausalityDialog = ref(false)
|
||||||
|
const causalityRecommendations = ref<any[]>([])
|
||||||
|
const causalityLoading = ref(false)
|
||||||
|
|
||||||
|
async function showCausalityRecommendations() {
|
||||||
|
if (!currentMap.value) { ElMessage.warning('请先选择或创建战略地图'); return }
|
||||||
|
causalityLoading.value = true
|
||||||
|
showCausalityDialog.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await api.get('/kpi-causality')
|
||||||
|
const items = r.data || r.items || r || []
|
||||||
|
causalityRecommendations.value = Array.isArray(items) ? items : []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载因果链失败')
|
||||||
|
causalityRecommendations.value = []
|
||||||
|
}
|
||||||
|
causalityLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCausality(rec: any) {
|
||||||
|
try {
|
||||||
|
await api.post(\\`/maps/\\${currentMap.value?.id}/connections\\`, { from: rec.source_kpi_code, to: rec.target_kpi_code })
|
||||||
|
ElMessage.success('连线已添加')
|
||||||
|
showCausalityDialog.value = false
|
||||||
|
loadMap()
|
||||||
|
} catch { ElMessage.error('添加失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAllCausality() {
|
||||||
|
let added = 0
|
||||||
|
for (const rec of causalityRecommendations.value) {
|
||||||
|
try {
|
||||||
|
await api.post(\\`/maps/\\${currentMap.value?.id}/connections\\`, { from: rec.source_kpi_code, to: rec.target_kpi_code })
|
||||||
|
added++
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
ElMessage.success(\\`已添加 \\${added}/\\${causalityRecommendations.value.length} 条因果链\\`)
|
||||||
|
showCausalityDialog.value = false
|
||||||
|
loadMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const r: any = await mapApi.list()
|
const r: any = await mapApi.list()
|
||||||
|
|||||||
Reference in New Issue
Block a user