diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..34c847aa Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/auth_middleware.cpython-312.pyc b/backend/app/__pycache__/auth_middleware.cpython-312.pyc new file mode 100644 index 00000000..621ce159 Binary files /dev/null and b/backend/app/__pycache__/auth_middleware.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 00000000..3c1131fa Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..084293ea Binary files /dev/null and b/backend/app/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/auth.cpython-312.pyc b/backend/app/api/__pycache__/auth.cpython-312.pyc new file mode 100644 index 00000000..686a1d79 Binary files /dev/null and b/backend/app/api/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/dashboard.cpython-312.pyc b/backend/app/api/__pycache__/dashboard.cpython-312.pyc new file mode 100644 index 00000000..ed734add Binary files /dev/null and b/backend/app/api/__pycache__/dashboard.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/data.cpython-312.pyc b/backend/app/api/__pycache__/data.cpython-312.pyc new file mode 100644 index 00000000..03660c48 Binary files /dev/null and b/backend/app/api/__pycache__/data.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/kpis.cpython-312.pyc b/backend/app/api/__pycache__/kpis.cpython-312.pyc new file mode 100644 index 00000000..4d920460 Binary files /dev/null and b/backend/app/api/__pycache__/kpis.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/maps.cpython-312.pyc b/backend/app/api/__pycache__/maps.cpython-312.pyc new file mode 100644 index 00000000..60119243 Binary files /dev/null and b/backend/app/api/__pycache__/maps.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/templates.cpython-312.pyc b/backend/app/api/__pycache__/templates.cpython-312.pyc new file mode 100644 index 00000000..492fb29d Binary files /dev/null and b/backend/app/api/__pycache__/templates.cpython-312.pyc differ diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index fbdabc9f..32fbdc49 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from typing import Optional from app.database import get_db from app.auth_middleware import require_auth, require_role -from app.models import StrategicMap, OperationLog +from app.models import StrategicMap, OperationLog, MapObjective import json router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], @@ -62,7 +62,7 @@ STRATEGIC_MAP_TEMPLATE = [ @router.get("") def list_maps(db: Session = Depends(get_db)): 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("") 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.commit() db.refresh(m) - return m_to_dict(m) + _sync_map_objectives(m, db) + return m_to_dict(m, db) @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.commit() db.refresh(m) - return m_to_dict(m) + _sync_map_objectives(m, db) + return m_to_dict(m, db) @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) db.commit() + # 同步目标到map_objectives表 + _sync_map_objectives(m, db) # ├─ 版本管理: draft → published 时自动创建快照 if old_status == "draft" and m.status == "published": _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): - return {c.name: getattr(m, c.name) for c in m.__table__.columns} +def m_to_dict(m, db: Session = None): + 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 # ── 战略回顾会 聚合接口 ────────────────────── diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..c80085e0 Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/budget_plan.cpython-312.pyc b/backend/app/models/__pycache__/budget_plan.cpython-312.pyc new file mode 100644 index 00000000..6ffb2f59 Binary files /dev/null and b/backend/app/models/__pycache__/budget_plan.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/cost_model.cpython-312.pyc b/backend/app/models/__pycache__/cost_model.cpython-312.pyc new file mode 100644 index 00000000..6906bf67 Binary files /dev/null and b/backend/app/models/__pycache__/cost_model.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/knowledge.cpython-312.pyc b/backend/app/models/__pycache__/knowledge.cpython-312.pyc new file mode 100644 index 00000000..9b9809ed Binary files /dev/null and b/backend/app/models/__pycache__/knowledge.cpython-312.pyc differ diff --git a/backend/scripts/migrate_map_objectives.py b/backend/scripts/migrate_map_objectives.py new file mode 100644 index 00000000..504231bb --- /dev/null +++ b/backend/scripts/migrate_map_objectives.py @@ -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("=== 完成 ===") diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 7300ae99..1752aa0b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -13,54 +13,6 @@ declare module 'vue' { export interface GlobalComponents { BulletChart: typeof import('./src/components/charts/BulletChart.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'] KnowledgePanel: typeof import('./src/components/KnowledgePanel.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'] WelcomeGuide: typeof import('./src/components/WelcomeGuide.vue')['default'] } - export interface GlobalDirectives { - vLoading: typeof import('element-plus/es')['ElLoadingDirective'] - } } diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index a09846c0..c5d5a611 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -24,7 +24,9 @@ 🔗 因果 - 保存 + 📷 导出图片 + 🔗 因果链推荐 + 保存 发布 版本历史 @@ -167,6 +169,35 @@ + + +
+
+
+ 🔗 KPI因果链推荐 + +
+
+
加载中...
+
暂无因果链推荐
+
+
+
+ {{ rec.source_kpi_code || rec.source_code }} + + {{ rec.target_kpi_code || rec.target_code }} + 强度: {{ rec.strength || '-' }} +
+ +
+
+
+ +
+
@@ -935,6 +966,48 @@ async function exportImage() { ElMessage.error('导出失败') } } +// ── KPI因果链推荐 ── +const showCausalityDialog = ref(false) +const causalityRecommendations = ref([]) +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 () => { const r: any = await mapApi.list()