فهرست منبع

✨ feat(组态编辑器): 增强组合绑定与编辑离开保护

- 新增组态节点递归展平与查找能力,属性读写支持组合内部子组件
- 扩展设备绑定目标选择,支持将设备点位绑定到组合内不同层级的组件属性
- 按目标节点分别存储绑定关系,并按“节点 + 属性”校验重复绑定
- 预览页面递归收集组合内部绑定,确保子组件能够正常接收实时数据
- 基于实际导出模型快照识别已保存和未保存状态,避免选择组件造成误判
- 在编辑器头部展示保存状态,并调整坐标、选中状态等提示的位置
- 增加 Cmd/Ctrl + S 快捷保存,保存过程中阻止重复提交
- 保存成功后同步更新基准快照,继续编辑时自动恢复未保存状态
- 增加返回、切换项目、路由离开和关闭页面时的未保存修改保护
- 优化未保存确认框的遮罩、图标、信息层级和紧凑按钮区域
- 修复新标签页没有历史记录时返回按钮无响应的问题,统一返回项目列表
Zimo 19 ساعت پیش
والد
کامیت
a72009cfd8

+ 47 - 3
src/components/mt-edit/components/layout/header-panel/index.vue

@@ -149,12 +149,23 @@
     </div>
 
     <section class="editor-header__actions">
+      <el-tag
+        v-if="headerPanelProps.realTimeData.show"
+        class="coordinate-status"
+        size="small"
+        effect="plain">
+        {{ headerPanelProps.realTimeData.text }}
+      </el-tag>
       <div v-if="headerPanelProps.selectedItemsId.length" class="selection-status">
         已选 {{ headerPanelProps.selectedItemsId.length }} 项
       </div>
-      <el-tag v-if="headerPanelProps.realTimeData.show" size="small" effect="plain">
-        {{ headerPanelProps.realTimeData.text }}
-      </el-tag>
+      <div
+        class="save-status"
+        :class="{ 'save-status--unsaved': headerPanelProps.unsaved }"
+        role="status">
+        <span class="save-status__dot"></span>
+        {{ headerPanelProps.unsaved ? '未保存' : '已保存' }}
+      </div>
       <tool-button label="返回" @click="emits('onReturnClick')">
         <svg-analysis name="return" />
       </tool-button>
@@ -172,6 +183,7 @@
         type="primary"
         :loading="headerPanelProps.saveLoading"
         :disabled="headerPanelProps.saveLoading"
+        :title="`保存(${systemShortcutKey} + S)`"
         @click="emits('onSaveClick')">
         <el-icon v-if="!headerPanelProps.saveLoading"><svg-analysis name="save" /></el-icon>
         {{ headerPanelProps.saveLoading ? '保存中' : '保存' }}
@@ -233,6 +245,7 @@ type HeaderPanelProps = {
   realTimeData: IRealTimeData
   useThumbnail?: boolean
   saveLoading?: boolean
+  unsaved?: boolean
 }
 
 const ToolButton = defineComponent({
@@ -282,6 +295,7 @@ const headerPanelProps = withDefaults(defineProps<HeaderPanelProps>(), {
   rightAside: true,
   useThumbnail: false,
   saveLoading: false,
+  unsaved: false,
   selectedItemsId: () => []
 })
 const emits = defineEmits([
@@ -440,6 +454,32 @@ const onDrawLineClick = () => {
   border-radius: 10px;
 }
 
+.save-status {
+  display: inline-flex;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1;
+  color: var(--el-color-success);
+  white-space: nowrap;
+  background: var(--el-color-success-light-9);
+  border-radius: 10px;
+  align-items: center;
+  gap: 6px;
+}
+
+.save-status__dot {
+  width: 6px;
+  height: 6px;
+  background: currentcolor;
+  border-radius: 50%;
+}
+
+.save-status--unsaved {
+  color: var(--el-color-warning-dark-2);
+  background: var(--el-color-warning-light-9);
+}
+
 :deep(.tool-button-wrap) {
   display: inline-flex;
 }
@@ -529,6 +569,10 @@ const onDrawLineClick = () => {
   background: var(--el-color-primary-light-9);
 }
 
+.coordinate-status {
+  flex: 0 0 auto;
+}
+
 .align-panel__title {
   margin-bottom: 8px;
   font-size: 13px;

+ 37 - 16
src/components/mt-edit/index.vue

@@ -23,6 +23,7 @@
           :real-time-data="globalStore.real_time_data"
           :use-thumbnail="mtEidtProps.useThumbnail"
           :save-loading="mtEidtProps.saveLoading"
+          :unsaved="is_unsaved"
           @on-group-click="mainPanelRef?.createGroupItem"
           @on-ungroup-click="mainPanelRef?.onUngroup"
           @on-delete-click="onDeleteClick"
@@ -122,7 +123,7 @@ import {
   ElMessage
 } from 'element-plus'
 import { globalStore, resetGlobalStore } from '@/components/mt-edit/store/global'
-import { computed, reactive, ref, useSlots } from 'vue'
+import { computed, onBeforeUnmount, onMounted, reactive, ref, useSlots } from 'vue'
 import DoneTree from '@/components/mt-edit/components/done-tree/index.vue'
 import { cacheStore } from './store/cache'
 import ExportJson from '@/components/mt-edit/components/export-json/index.vue'
@@ -186,6 +187,20 @@ const import_visible = ref(false)
 const export_visible = ref(false)
 const done_json_tree_visiable = ref(false)
 const line_append_enable = ref(false)
+const saved_snapshot = ref('')
+const createExportJson = () => {
+  const { exportJson } = genExportJson(
+    globalStore.canvasCfg,
+    globalStore.gridCfg,
+    globalStore.done_json
+  )
+  return exportJson
+}
+const getExportJson = () => objectDeepClone<IExportJson>(createExportJson())
+const current_snapshot = computed(() => JSON.stringify(createExportJson()))
+const is_unsaved = computed(
+  () => saved_snapshot.value !== '' && current_snapshot.value !== saved_snapshot.value
+)
 const onDeleteClick = () => {
   globalStore.deleteSelectedItems()
   cacheStore.addHistory(globalStore.done_json)
@@ -293,22 +308,12 @@ const onImportYes = async () => {
   }
 }
 const onPreviewClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onPreviewClick', exportJson)
+  emits('onPreviewClick', getExportJson())
 }
 const onSaveClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onSaveClick', exportJson)
+  if (!mtEidtProps.saveLoading) {
+    emits('onSaveClick', getExportJson())
+  }
 }
 const onThumbnailClick = () => {
   emits('onThumbnailClick')
@@ -323,8 +328,19 @@ const setImportJson = (exportJson: IExportJson) => {
   globalStore.setGlobalStoreDoneJson(importDoneJson)
   cacheStore.history = [objectDeepClone(importDoneJson)]
   cacheStore.historyIndex = 0
+  saved_snapshot.value = current_snapshot.value
   return true
 }
+const markSaved = (exportJson: IExportJson = getExportJson()) => {
+  saved_snapshot.value = JSON.stringify(exportJson)
+}
+const hasUnsavedChanges = () => is_unsaved.value
+const onGlobalKeyDown = (event: KeyboardEvent) => {
+  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
+    event.preventDefault()
+    onSaveClick()
+  }
+}
 const reset = () => {
   resetGlobalStore()
   cacheStore.setBoundingBox([])
@@ -336,10 +352,15 @@ const reset = () => {
   export_visible.value = false
   done_json_tree_visiable.value = false
   line_append_enable.value = false
+  saved_snapshot.value = ''
 }
+onMounted(() => document.addEventListener('keydown', onGlobalKeyDown, true))
+onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeyDown, true))
 defineExpose({
   setImportJson,
-  reset
+  reset,
+  markSaved,
+  hasUnsavedChanges
 })
 </script>
 <style scoped>

+ 17 - 3
src/components/mt-edit/utils/index.ts

@@ -783,6 +783,20 @@ export const handleAlign = (
   useUpdateSysLine(sys_lines, selected_done_json, canvasDom, scale)
   return selected_done_json
 }
+/**
+ * 展平组态节点,包含组合内的所有子节点
+ */
+export const flattenDoneJson = (json_arr: IDoneJson[]): IDoneJson[] => {
+  return json_arr.flatMap((item) => [item, ...flattenDoneJson(item.children || [])])
+}
+
+/**
+ * 按 ID 查找顶层或组合内的组态节点
+ */
+export const findDoneJsonItem = (json_arr: IDoneJson[], id: string) => {
+  return flattenDoneJson(json_arr).find((item) => item.id === id)
+}
+
 /**
  * 设置图形属性
  * @param id
@@ -793,9 +807,9 @@ export const handleAlign = (
  */
 export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJson[]) => {
   return new Promise((res) => {
-    const find_item = json_arr.find((f) => f.id === id)
+    const find_item = findDoneJsonItem(json_arr, id)
     if (!find_item) {
-      res({
+      return res({
         status: false,
         msg: '要设置的id不存在'
       })
@@ -808,7 +822,7 @@ export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJs
   })
 }
 export const getItemAttr = (id: string, key: string, json_arr: IDoneJson[]) => {
-  const find_item = json_arr.find((f) => f.id === id)
+  const find_item = findDoneJsonItem(json_arr, id)
   if (!find_item) {
     return null
   }

+ 80 - 25
src/views/maotu/components/DeviceBindPanel.vue

@@ -156,10 +156,11 @@
                       :prop="`deviceBinds.${index}.nodeProp`"
                       :rules="rules.nodeProp">
                       <el-select
-                        v-model="bindItem.nodeProp"
+                        :model-value="getBindTargetValue(bindItem)"
                         filterable
                         clearable
-                        placeholder="选择要驱动的组件属性">
+                        placeholder="选择要驱动的组件属性"
+                        @update:model-value="onNodePropChange(bindItem, $event)">
                         <el-option
                           v-for="nodeProp in nodePropOptions"
                           :key="nodeProp.value"
@@ -226,7 +227,7 @@
                   <span class="device-bind-card__mapping-arrow">→</span>
                   <div>
                     <span>组件属性</span>
-                    <strong>{{ getNodePropLabel(bindItem.nodeProp) }}</strong>
+                    <strong>{{ getNodePropLabel(bindItem) }}</strong>
                   </div>
                 </div>
               </template>
@@ -248,15 +249,24 @@
 <script setup lang="ts">
 import type { WebtopoProjectLinkedDeviceVO } from '@/api/pms/maotu'
 import type { IDoneJson, IDoneJsonDeviceBind } from '@/components/mt-edit/store/types'
+import { flattenDoneJson } from '@/components/mt-edit/utils'
 import { Check, Connection, Delete, EditPen, Monitor, Plus } from '@element-plus/icons-vue'
 import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
 import { computed, nextTick, reactive, ref } from 'vue'
 
 type DeviceBindRow = IDoneJsonDeviceBind & {
+  targetNodeId?: string
   editing?: boolean
   isNew?: boolean
 }
 
+type NodePropOption = {
+  label: string
+  value: string
+  nodeId: string
+  nodeProp: string
+}
+
 type Props = {
   item: IDoneJson
   devices?: WebtopoProjectLinkedDeviceVO[]
@@ -279,15 +289,38 @@ const rules: Record<string, FormItemRule | FormItemRule[]> = {
   nodeProp: [{ required: true, message: '请选择图形属性', trigger: 'change' }]
 }
 
-const nodePropOptions = computed(() => {
-  return Object.entries(props.item.props || {})
+function collectNodePropOptions(
+  item: IDoneJson,
+  path: string[] = [],
+  options: NodePropOption[] = []
+) {
+  Object.entries(item.props || {})
     .filter(([, prop]) => !prop.disabled && prop.bindable !== false)
-    .map(([key, prop]) => ({
-      label: prop.title,
-      value: `props.${key}.val`
-    }))
-})
-const bindCount = computed(() => props.item.deviceBinds?.length || 0)
+    .forEach(([key, prop]) => {
+      const nodeProp = `props.${key}.val`
+      options.push({
+        label: path.length ? `${path.join(' / ')} · ${prop.title}` : prop.title,
+        value: `${item.id}::${nodeProp}`,
+        nodeId: item.id,
+        nodeProp
+      })
+    })
+
+  item.children?.forEach((child, index) => {
+    collectNodePropOptions(
+      child,
+      [...path, `${index + 1}. ${child.title || '未命名组件'}`],
+      options
+    )
+  })
+  return options
+}
+
+const allNodes = computed(() => flattenDoneJson([props.item]))
+const nodePropOptions = computed(() => collectNodePropOptions(props.item))
+const bindCount = computed(() =>
+  allNodes.value.reduce((count, item) => count + (item.deviceBinds?.length || 0), 0)
+)
 const savedBindCount = computed(() => bindCount.value)
 const hasEditingBind = computed(() => formModel.deviceBinds.some((item) => item.editing))
 
@@ -324,29 +357,47 @@ function getDevicePropLabel(bindItem: DeviceBindRow) {
   )
 }
 
-function getNodePropLabel(nodeProp?: string) {
+function getBindTargetValue(bindItem: DeviceBindRow) {
   return (
-    nodePropOptions.value.find((item) => item.value === nodeProp)?.label || nodeProp || '未知属性'
+    nodePropOptions.value.find(
+      (item) => item.nodeId === bindItem.targetNodeId && item.nodeProp === bindItem.nodeProp
+    )?.value || ''
   )
 }
 
-function updateItemDeviceBinds(item: IDoneJson, deviceBinds: IDoneJsonDeviceBind[]) {
-  item.deviceBinds = deviceBinds
+function onNodePropChange(bindItem: DeviceBindRow, value?: string) {
+  const option = nodePropOptions.value.find((item) => item.value === value)
+  bindItem.targetNodeId = option?.nodeId
+  bindItem.nodeProp = option?.nodeProp || ''
 }
 
-function syncDeviceBinds() {
-  updateItemDeviceBinds(
-    props.item,
-    formModel.deviceBinds.map(({ editing, isNew, ...item }) => item)
+function getNodePropLabel(bindItem: DeviceBindRow) {
+  return (
+    nodePropOptions.value.find(
+      (item) => item.nodeId === bindItem.targetNodeId && item.nodeProp === bindItem.nodeProp
+    )?.label ||
+    bindItem.nodeProp ||
+    '未知属性'
   )
 }
 
+function syncDeviceBinds() {
+  allNodes.value.forEach((node) => {
+    node.deviceBinds = formModel.deviceBinds
+      .filter((item) => item.targetNodeId === node.id)
+      .map(({ targetNodeId, editing, isNew, ...item }) => item)
+  })
+}
+
 async function openDrawer() {
-  formModel.deviceBinds = (props.item.deviceBinds || []).map((item) => ({
-    ...item,
-    editing: false,
-    isNew: false
-  }))
+  formModel.deviceBinds = allNodes.value.flatMap((node) =>
+    (node.deviceBinds || []).map((item) => ({
+      ...item,
+      targetNodeId: node.id,
+      editing: false,
+      isNew: false
+    }))
+  )
   editBackup.value = undefined
   drawerVisible.value = true
 }
@@ -362,6 +413,7 @@ function addBind() {
     deviceId: undefined,
     deviceProp: '',
     nodeProp: '',
+    targetNodeId: undefined,
     editing: true,
     isNew: true
   })
@@ -415,7 +467,10 @@ async function saveBind(index: number) {
 
   const currentItem = formModel.deviceBinds[index]
   const duplicateNodeProp = formModel.deviceBinds.some(
-    (item, itemIndex) => itemIndex !== index && item.nodeProp === currentItem.nodeProp
+    (item, itemIndex) =>
+      itemIndex !== index &&
+      item.targetNodeId === currentItem.targetNodeId &&
+      item.nodeProp === currentItem.nodeProp
   )
   if (duplicateNodeProp) {
     ElMessage.warning('该组件属性已经存在设备绑定,请选择其他属性')

+ 146 - 5
src/views/maotu/edit.vue

@@ -8,10 +8,10 @@ import type { IExportJson } from '@/components/mt-edit/components/types'
 import { useGenThumbnail } from '@/components/mt-edit/composables/thumbnail'
 import { MtEdit } from '@/export'
 import { Canvg } from 'canvg'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import html2canvas from 'html2canvas'
-import { nextTick, onMounted, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
 import DeviceBindPanel from './components/DeviceBindPanel.vue'
 const route = useRoute()
 const router = useRouter()
@@ -48,6 +48,8 @@ const loadProject = async () => {
     if (data?.dataModel) {
       await nextTick()
       MtEditRef.value?.setImportJson(data.dataModel as IExportJson)
+    } else {
+      MtEditRef.value?.markSaved()
     }
   } catch {
     ElMessage.error('拓扑数据加载失败')
@@ -119,6 +121,9 @@ const onPreviewClick = (exportJson: IExportJson) => {
   window.open(routeUrl.href, '_blank')
 }
 const onSaveClick = async (dataModel: IExportJson) => {
+  if (saveLoading.value) {
+    return
+  }
   const detail = projectDetail.value
   if (!detail?.id) {
     ElMessage.error('项目信息不存在,无法保存')
@@ -147,20 +152,68 @@ const onSaveClick = async (dataModel: IExportJson) => {
       thumbnail,
       dataModel: JSON.stringify(dataModel)
     }
+    MtEditRef.value?.markSaved(dataModel)
     ElMessage.success('更新成功')
   } finally {
     saveLoading.value = false
   }
 }
 const onReturnClick = () => {
-  router.go(-1)
+  router.push('/maotu/list')
 }
 const onThumbnailClick = () => {
   useGenThumbnail()
 }
 
+const hasUnsavedChanges = () => MtEditRef.value?.hasUnsavedChanges() ?? false
+const confirmLeave = async () => {
+  if (!hasUnsavedChanges()) {
+    return true
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      '当前项目还有未保存的内容。离开后,这些修改将无法恢复。',
+      '未保存的修改',
+      {
+        confirmButtonText: '确定离开',
+        cancelButtonText: '继续编辑',
+        type: 'warning',
+        distinguishCancelAndClose: true,
+        customClass: 'maotu-unsaved-message-box',
+        modalClass: 'maotu-unsaved-overlay',
+        confirmButtonClass: 'maotu-unsaved-message-box__confirm',
+        cancelButtonClass: 'maotu-unsaved-message-box__cancel',
+        showClose: false,
+        closeOnClickModal: false,
+        roundButton: true,
+        autofocus: false
+      }
+    )
+    return true
+  } catch {
+    return false
+  }
+}
+const onBeforeUnload = (event: BeforeUnloadEvent) => {
+  if (!hasUnsavedChanges()) {
+    return
+  }
+  event.preventDefault()
+  event.returnValue = ''
+}
+
 onMounted(() => {
   resetAndLoadProject()
+  window.addEventListener('beforeunload', onBeforeUnload)
+})
+onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
+onBeforeRouteLeave(confirmLeave)
+onBeforeRouteUpdate((to, from) => {
+  if (to.params.id !== from.params.id) {
+    return confirmLeave()
+  }
+  return true
 })
 
 watch(() => route.params.id, resetAndLoadProject)
@@ -187,4 +240,92 @@ watch(() => route.params.id, resetAndLoadProject)
   </div>
 </template>
 
-<style scoped></style>
+<style>
+.maotu-unsaved-overlay {
+  background: rgb(15 23 42 / 46%);
+  backdrop-filter: blur(2px);
+}
+
+.maotu-unsaved-message-box.el-message-box {
+  --el-messagebox-width: 440px;
+  --el-messagebox-border-radius: 16px;
+
+  padding: 0;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid rgb(226 232 240 / 92%);
+  box-shadow: 0 24px 64px rgb(15 23 42 / 22%);
+}
+
+.maotu-unsaved-message-box .el-message-box__header {
+  padding: 22px 24px 10px;
+}
+
+.maotu-unsaved-message-box .el-message-box__title {
+  font-size: 18px;
+  font-weight: 700;
+  line-height: 26px;
+  color: #172033;
+}
+
+.maotu-unsaved-message-box .el-message-box__content {
+  padding: 4px 24px 22px;
+}
+
+.maotu-unsaved-message-box .el-message-box__container {
+  align-items: flex-start;
+  gap: 14px;
+}
+
+.maotu-unsaved-message-box .el-message-box__status {
+  display: inline-flex;
+  width: 40px;
+  height: 40px;
+  font-size: 21px;
+  color: #f59e0b;
+  background: #fff7e6;
+  border-radius: 12px;
+  flex: 0 0 40px;
+  align-items: center;
+  justify-content: center;
+}
+
+.maotu-unsaved-message-box .el-message-box__message {
+  padding-top: 1px;
+  font-size: 14px;
+  line-height: 22px;
+  color: #667085;
+}
+
+.maotu-unsaved-message-box .el-message-box__btns {
+  padding: 11px 20px 13px;
+  background: #f8fafc;
+  border-top: 1px solid #edf1f5;
+  gap: 8px;
+}
+
+.maotu-unsaved-message-box .el-message-box__btns .el-button {
+  height: 34px;
+  padding: 0 16px;
+  margin: 0;
+  font-weight: 600;
+  border-radius: 9px;
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__cancel {
+  color: #475467;
+  background: #fff;
+  border-color: #d8dee8;
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__cancel:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-7);
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__confirm {
+  min-width: 96px;
+  box-shadow: 0 6px 14px rgb(64 158 255 / 24%);
+}
+</style>

+ 2 - 1
src/views/maotu/preview.vue

@@ -22,6 +22,7 @@
 import { WebtopoProjectApi, parseWebtopoDataModel } from '@/api/pms/maotu'
 import { IotDeviceApi } from '@/api/pms/device'
 import type { IExportJson } from '@/components/mt-edit/components/types'
+import { flattenDoneJson } from '@/components/mt-edit/utils'
 import { MtPreview } from '@/export'
 import { isAppWebtopoPreview } from '@/utils/auth'
 import { ElMessage } from 'element-plus'
@@ -62,7 +63,7 @@ const getDeviceBindItems = () => {
     ? previewData.value.pages.flatMap((page) => page.json)
     : previewData.value?.json || []
 
-  return previewItems.flatMap((item) =>
+  return flattenDoneJson(previewItems).flatMap((item) =>
     (item.deviceBinds || [])
       .filter((bind) => bind.deviceId && bind.deviceProp && bind.nodeProp)
       .map((bind) => ({