Zimo 2 天之前
父節點
當前提交
feda650d83

+ 399 - 62
src/components/mt-edit/components/done-tree/index.vue

@@ -1,75 +1,170 @@
 <template>
-  <el-tree
-    :data="doneTreeProps.doneJson"
-    :props="defaultProps"
-    @node-click="handleNodeClick"
-    :default-expand-all="true"
-    :expand-on-click-node="false"
-    :highlight-current="true"
-    node-key="id"
-    :current-node-key="current_node_key">
-    <template #default="{ node, data }">
-      <div class="flex justify-between w-9/10">
-        <div>{{ node.label }}</div>
-        <div class="flex items-center">
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!canMoveUp(data)"
-            title="上移一层"
-            @click.stop="moveLayer(data, 'up')">
-            <el-icon :size="16">
-              <ArrowUp />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!canMoveDown(data)"
-            title="下移一层"
-            @click.stop="moveLayer(data, 'down')">
-            <el-icon :size="16">
-              <ArrowDown />
-            </el-icon>
-          </el-button>
-          <el-button text circle size="small" class="mr-10px">
-            <el-icon :title="data.hide ? '隐藏' : '显示'" :size="20" @click.stop="changeHide(data)">
-              <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
-            </el-icon>
-          </el-button>
-        </div>
+  <div class="layer-panel">
+    <div class="layer-panel__overview">
+      <div class="layer-stat">
+        <strong>{{ doneTreeProps.doneJson.length }}</strong>
+        <span>全部组件</span>
       </div>
-    </template>
-  </el-tree>
+      <div class="layer-stat layer-stat--hidden">
+        <strong>{{ hidden_count }}</strong>
+        <span>已隐藏</span>
+      </div>
+      <div class="layer-stat layer-stat--visible">
+        <strong>{{ visible_count }}</strong>
+        <span>画布可见</span>
+      </div>
+    </div>
+
+    <div class="layer-panel__toolbar">
+      <el-input v-model="search_keyword" clearable placeholder="搜索组件名称或类型">
+        <template #prefix>
+          <el-icon><Search /></el-icon>
+        </template>
+      </el-input>
+      <div class="layer-panel__batch-actions">
+        <el-button :disabled="hidden_count === 0" @click="setAllHidden(false)">
+          <el-icon><View /></el-icon>
+          全部显示
+        </el-button>
+        <el-button
+          :disabled="doneTreeProps.doneJson.length === 0 || visible_count === 0"
+          @click="setAllHidden(true)">
+          <el-icon><Hide /></el-icon>
+          全部隐藏
+        </el-button>
+      </div>
+    </div>
+
+    <div class="layer-panel__list-header">
+      <span>图层列表</span>
+      <span>{{ filtered_done_json.length }} 项</span>
+    </div>
+
+    <el-scrollbar class="layer-panel__scrollbar">
+      <el-tree
+        class="layer-tree"
+        :data="filtered_done_json"
+        :props="defaultProps"
+        :expand-on-click-node="false"
+        :highlight-current="true"
+        node-key="id"
+        empty-text="没有找到匹配的组件"
+        :current-node-key="current_node_key"
+        @node-click="handleNodeClick">
+        <template #default="{ node, data }">
+          <div class="layer-node" :class="{ 'layer-node--hidden': data.hide }">
+            <span class="layer-node__index">{{ getLayerIndex(data) + 1 }}</span>
+            <div class="layer-node__info">
+              <span class="layer-node__name" :title="node.label">{{ node.label }}</span>
+              <span class="layer-node__meta">
+                {{ getTypeLabel(data.type) }} · 第 {{ getLayerIndex(data) + 1 }} 层
+              </span>
+            </div>
+            <div class="layer-node__actions">
+              <el-button
+                text
+                circle
+                :disabled="!canMoveUp(data)"
+                title="上移一层"
+                @click.stop="moveLayer(data, 'up')">
+                <el-icon><ArrowUp /></el-icon>
+              </el-button>
+              <el-button
+                text
+                circle
+                :disabled="!canMoveDown(data)"
+                title="下移一层"
+                @click.stop="moveLayer(data, 'down')">
+                <el-icon><ArrowDown /></el-icon>
+              </el-button>
+              <el-button
+                text
+                circle
+                :class="{ 'layer-node__visibility--hidden': data.hide }"
+                :title="data.hide ? '显示组件' : '隐藏组件'"
+                @click.stop="changeHide(data)">
+                <el-icon>
+                  <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
+                </el-icon>
+              </el-button>
+            </div>
+          </div>
+        </template>
+        <template #empty>
+          <el-empty :image-size="72" :description="empty_description" />
+        </template>
+      </el-tree>
+    </el-scrollbar>
+
+    <div class="layer-panel__hint">点击图层可定位组件,右侧按钮用于调整层级和可见性</div>
+  </div>
 </template>
 
 <script lang="ts" setup>
-import { ElTree, ElButton, ElIcon } from 'element-plus'
-import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
-import { computed } from 'vue'
-import type { IDoneJson } from '@/components/mt-edit/store/types'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
+import type { IDoneJson } from '@/components/mt-edit/store/types'
+import { ArrowDown, ArrowUp, Hide, Search, View } from '@element-plus/icons-vue'
+import { ElButton, ElEmpty, ElIcon, ElInput, ElScrollbar, ElTree } from 'element-plus'
+import { computed, ref } from 'vue'
+
 type DoneTree = {
   doneJson: IDoneJson[]
-  selectedItemsId: string[] //已选中组件的id
+  selectedItemsId: string[]
 }
-const doneTreeProps = withDefaults(defineProps<DoneTree>(), {})
-
-const emits = defineEmits(['updateSelectedItemsId', 'updateSelectedIdHide', 'moveLayer'])
 
+const doneTreeProps = withDefaults(defineProps<DoneTree>(), {})
+const emits = defineEmits([
+  'updateSelectedItemsId',
+  'updateSelectedIdHide',
+  'updateAllHide',
+  'moveLayer'
+])
+const search_keyword = ref('')
+const hidden_count = computed(() => doneTreeProps.doneJson.filter((item) => item.hide).length)
+const visible_count = computed(() => doneTreeProps.doneJson.length - hidden_count.value)
+const filtered_done_json = computed(() => {
+  const keyword = search_keyword.value.trim().toLocaleLowerCase()
+  if (!keyword) {
+    return doneTreeProps.doneJson
+  }
+  return doneTreeProps.doneJson.filter((item) => {
+    return (
+      item.title.toLocaleLowerCase().includes(keyword) ||
+      getTypeLabel(item.type).toLocaleLowerCase().includes(keyword)
+    )
+  })
+})
+const empty_description = computed(() =>
+  search_keyword.value.trim() ? '没有找到匹配的组件' : '画布中还没有组件'
+)
 const current_node_key = computed(() => {
-  return doneTreeProps.selectedItemsId.length == 1 ? doneTreeProps.selectedItemsId[0] : ''
+  return doneTreeProps.selectedItemsId.length === 1 ? doneTreeProps.selectedItemsId[0] : ''
 })
+const typeLabelMap: Record<IDoneJson['type'], string> = {
+  svg: '矢量组件',
+  vue: '交互组件',
+  img: '图片',
+  'custom-svg': '自定义图形',
+  group: '组合',
+  'sys-line': '连线'
+}
+const defaultProps = {
+  children: 'nochildren',
+  label: 'title'
+}
+
+const getTypeLabel = (type: IDoneJson['type']) => typeLabelMap[type] || '组件'
+const getLayerIndex = (data: IDoneJson) => {
+  return doneTreeProps.doneJson.findIndex((item) => item.id === data.id)
+}
 const handleNodeClick = (data: IDoneJson) => {
   emits('updateSelectedItemsId', data.id)
 }
 const changeHide = (data: IDoneJson) => {
   emits('updateSelectedIdHide', data.id)
 }
-const getLayerIndex = (data: IDoneJson) => {
-  return doneTreeProps.doneJson.findIndex((item) => item.id === data.id)
+const setAllHidden = (hidden: boolean) => {
+  emits('updateAllHide', hidden)
 }
 const canMoveUp = (data: IDoneJson) => {
   const index = getLayerIndex(data)
@@ -79,13 +174,255 @@ const canMoveDown = (data: IDoneJson) => {
   return getLayerIndex(data) > 0
 }
 const moveLayer = (data: IDoneJson, direction: 'up' | 'down') => {
-  emits('moveLayer', {
-    id: data.id,
-    direction
-  })
-}
-const defaultProps = {
-  children: 'nochildren',
-  label: 'title'
+  emits('moveLayer', { id: data.id, direction })
 }
 </script>
+
+<style scoped>
+.layer-panel {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 16px 18px 12px;
+  background: #f7f9fc;
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.layer-panel__overview {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 9px;
+  margin-bottom: 14px;
+}
+
+.layer-stat {
+  display: flex;
+  min-width: 0;
+  padding: 10px 12px;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.layer-stat strong {
+  font-size: 18px;
+  line-height: 22px;
+  color: #344054;
+}
+
+.layer-stat span {
+  overflow: hidden;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.layer-stat--hidden strong {
+  color: #f59e0b;
+}
+
+.layer-stat--visible strong {
+  color: var(--el-color-primary);
+}
+
+.layer-panel__toolbar {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.layer-panel__toolbar :deep(.el-input) {
+  flex: 1;
+}
+
+.layer-panel__toolbar :deep(.el-input__wrapper) {
+  border-radius: 9px;
+  box-shadow: 0 0 0 1px #dbe1ea inset;
+}
+
+.layer-panel__batch-actions {
+  display: flex;
+  gap: 7px;
+}
+
+.layer-panel__batch-actions :deep(.el-button) {
+  height: 32px;
+  padding: 0 10px;
+  margin: 0;
+  border-radius: 8px;
+}
+
+.layer-panel__batch-actions :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.layer-panel__list-header {
+  display: flex;
+  padding: 0 10px 8px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 18px;
+  color: #667085;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.layer-panel__scrollbar {
+  min-height: 0;
+  flex: 1;
+}
+
+.layer-tree {
+  background: transparent;
+}
+
+.layer-tree :deep(.el-tree-node) {
+  margin-bottom: 4px;
+}
+
+.layer-tree :deep(.el-tree-node__content) {
+  height: 50px;
+  padding: 0 8px !important;
+  background: #fff;
+  border: 1px solid #e5e9f0;
+  border-radius: 9px;
+  transition:
+    background 0.18s ease,
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.layer-tree :deep(.el-tree-node__content:hover) {
+  background: #f8fbff;
+  border-color: var(--el-color-primary-light-7);
+}
+
+.layer-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-5);
+  box-shadow: 0 3px 10px rgb(64 158 255 / 10%);
+}
+
+.layer-node {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+}
+
+.layer-node__index {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #667085;
+  background: #f1f4f8;
+  border-radius: 7px;
+  flex: 0 0 26px;
+  align-items: center;
+  justify-content: center;
+}
+
+.layer-node__info {
+  display: flex;
+  min-width: 0;
+  padding-left: 10px;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.layer-node__name {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 17px;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.layer-node__meta {
+  font-size: 11px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.layer-node__actions {
+  display: flex;
+  padding-left: 8px;
+  flex: 0 0 auto;
+  gap: 2px;
+}
+
+.layer-node__actions :deep(.el-button) {
+  width: 28px;
+  height: 28px;
+  margin: 0;
+  color: #667085;
+}
+
+.layer-node__actions :deep(.el-button:hover) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.layer-node__actions :deep(.layer-node__visibility--hidden) {
+  color: #f59e0b;
+  background: #fff7e6;
+}
+
+.layer-node--hidden .layer-node__name {
+  color: #98a2b3;
+  text-decoration: line-through;
+}
+
+.layer-panel__hint {
+  padding-top: 10px;
+  margin-top: 8px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-align: center;
+  border-top: 1px solid #e1e6ed;
+}
+
+.dark .layer-panel {
+  background: #181f2b;
+}
+
+.dark .layer-stat,
+.dark .layer-tree :deep(.el-tree-node__content) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .layer-stat strong,
+.dark .layer-node__name {
+  color: #dbe3ed;
+}
+
+.dark .layer-node__index {
+  color: #aeb8c7;
+  background: #2b3444;
+}
+
+.dark .layer-tree :deep(.el-tree-node__content:hover) {
+  background: #252e3d;
+}
+
+.dark .layer-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  background: rgb(64 158 255 / 14%);
+  border-color: var(--el-color-primary-dark-2);
+}
+
+.dark .layer-panel__hint {
+  border-color: #303949;
+}
+</style>

+ 438 - 286
src/components/mt-edit/components/layout/header-panel/index.vue

@@ -1,274 +1,186 @@
 <template>
-  <div class="flex justify-between" style="width: 100%">
-    <div class="flex items-center justify-between w-200px">
-      <div class="editor-brand">
-        <el-image
-          class="editor-brand__logo"
-          :src="deepOilLogo" />
-        <el-text
-          class="editor-brand__title animate__animated animate__bounceIn animate__slow animate__1 animate__delay-0s"
-          >DeepOil</el-text
-        >
+  <div class="editor-header">
+    <section class="editor-header__brand">
+      <el-image class="editor-brand__logo" :src="deepOilLogo" />
+      <div class="editor-brand__text">
+        <span class="editor-brand__name">拓扑设计</span>
+        <span class="editor-brand__project">{{ headerPanelProps.title }}</span>
       </div>
-      <el-button
-        text
-        circle
-        size="small"
+      <tool-button
+        :label="headerPanelProps.leftAside ? '收起' : '展开'"
         @click="emits('update:leftAside', !headerPanelProps.leftAside)">
-        <el-icon :size="20">
-          <svg-analysis v-if="headerPanelProps.leftAside" name="menu-fold" />
-          <svg-analysis v-else name="menu-unfold" />
-        </el-icon>
-      </el-button>
-    </div>
-    <div class="flex justify-between" style="width: calc(100% - 440px)">
-      <div class="flex items-center">
-        <el-button-group>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.undoEnabled"
-            @click="emits('onUndoClick')">
-            <el-icon :title="`撤销 ${systemShortcutKey}+Z`" :size="20">
-              <svg-analysis name="undo" />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.redoEnabled"
-            @click="emits('onRedoClick')">
-            <el-icon :title="`重做 ${systemShortcutKey}+Y`" :size="20">
-              <svg-analysis name="redo" />
-            </el-icon>
-          </el-button>
-        </el-button-group>
-        <el-divider direction="vertical" />
-        <el-button
-          text
-          circle
-          size="small"
+        <svg-analysis :name="headerPanelProps.leftAside ? 'menu-fold' : 'menu-unfold'" />
+      </tool-button>
+    </section>
+
+    <div class="editor-header__toolbar">
+      <div class="tool-group">
+        <tool-button
+          label="撤销"
+          :disabled="!headerPanelProps.undoEnabled"
+          @click="emits('onUndoClick')">
+          <svg-analysis name="undo" />
+        </tool-button>
+        <tool-button
+          label="重做"
+          :disabled="!headerPanelProps.redoEnabled"
+          @click="emits('onRedoClick')">
+          <svg-analysis name="redo" />
+        </tool-button>
+        <tool-button
+          label="删除"
           :disabled="!headerPanelProps.deleteEnabled"
+          danger
           @click="onDeleteClick">
-          <el-icon title="删除 delete" :class="``" :size="20">
-            <svg-analysis name="delete" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="onTreeClick">
-          <el-icon title="组件树" :size="20">
-            <svg-analysis name="tree-list" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button-group>
-          <el-button text circle size="small" @click="onImportClick">
-            <el-icon title="导入数据模型" :size="20">
-              <svg-analysis name="import-json" />
-            </el-icon>
-          </el-button>
-          <el-button text circle size="small" @click="onExportClick">
-            <el-icon title="导出数据模型" :size="20">
-              <svg-analysis name="export-json" />
-            </el-icon> </el-button
-        ></el-button-group>
-        <el-divider direction="vertical" />
+          <svg-analysis name="delete" />
+        </tool-button>
+      </div>
+
+      <div class="tool-group">
+        <tool-button label="组件树" @click="onTreeClick">
+          <svg-analysis name="tree-list" />
+        </tool-button>
+        <tool-button label="导入" @click="onImportClick">
+          <svg-analysis name="import-json" />
+        </tool-button>
+        <tool-button label="导出" @click="onExportClick">
+          <svg-analysis name="export-json" />
+        </tool-button>
+      </div>
+
+      <div class="tool-group">
         <el-popover
           placement="bottom"
-          :width="240"
-          trigger="hover"
+          :width="460"
+          trigger="click"
           :disabled="!headerPanelProps.alignEnabled">
           <template #reference>
-            <el-button text circle size="small" :disabled="!headerPanelProps.alignEnabled">
-              <el-icon title="对齐" :size="20">
-                <svg-analysis name="align" />
-              </el-icon>
-            </el-button>
+            <tool-button label="对齐" :disabled="!headerPanelProps.alignEnabled">
+              <svg-analysis name="align" />
+            </tool-button>
           </template>
-          <div class="flex justify-center">
-            <el-button-group>
-              <el-button text circle size="small" @click="alignSelected('left')">
-                <el-icon title="左对齐" :size="20">
-                  <svg-analysis name="align-left" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('horizontally')">
-                <el-icon title="水平居中" :size="20">
-                  <svg-analysis name="align-horizontally" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('right')">
-                <el-icon title="右对齐" :size="20">
-                  <svg-analysis name="align-right" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('top')">
-                <el-icon title="上对齐" :size="20">
-                  <svg-analysis name="align-top" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('vertically')">
-                <el-icon title="垂直居中" :size="20">
-                  <svg-analysis name="align-vertical" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('bottom')">
-                <el-icon title="下对齐" :size="20">
-                  <svg-analysis name="align-bottom" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('horizontal-distribution')">
-                <el-icon title="水平分布" :size="20">
-                  <svg-analysis name="horizontal-distribution" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('vertical-distribution')">
-                <el-icon title="垂直分布" :size="20">
-                  <svg-analysis name="vertical-distribution" />
-                </el-icon>
-              </el-button>
-            </el-button-group>
+          <div class="align-panel">
+            <div class="align-panel__title">对齐与分布</div>
+            <div class="align-panel__actions">
+              <tool-button label="左对齐" compact @click="alignSelected('left')">
+                <svg-analysis name="align-left" />
+              </tool-button>
+              <tool-button label="水平居中" compact @click="alignSelected('horizontally')">
+                <svg-analysis name="align-horizontally" />
+              </tool-button>
+              <tool-button label="右对齐" compact @click="alignSelected('right')">
+                <svg-analysis name="align-right" />
+              </tool-button>
+              <tool-button label="顶部" compact @click="alignSelected('top')">
+                <svg-analysis name="align-top" />
+              </tool-button>
+              <tool-button label="垂直居中" compact @click="alignSelected('vertically')">
+                <svg-analysis name="align-vertical" />
+              </tool-button>
+              <tool-button label="底部" compact @click="alignSelected('bottom')">
+                <svg-analysis name="align-bottom" />
+              </tool-button>
+              <tool-button
+                label="水平分布"
+                compact
+                @click="alignSelected('horizontal-distribution')">
+                <svg-analysis name="horizontal-distribution" />
+              </tool-button>
+              <tool-button label="垂直分布" compact @click="alignSelected('vertical-distribution')">
+                <svg-analysis name="vertical-distribution" />
+              </tool-button>
+            </div>
           </div>
         </el-popover>
-        <el-divider direction="vertical" />
-        <el-button-group>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.groupEnabled"
-            @click="onGroupClick">
-            <el-icon title="组合" :size="20">
-              <svg-analysis name="group" />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.unGroupEnabled"
-            @click="onUngroupClick">
-            <el-icon title="取消组合" :size="20">
-              <svg-analysis name="ungroup" />
-            </el-icon>
-          </el-button>
-        </el-button-group>
-        <el-divider direction="vertical" v-if="!is_npm_env" />
-        <el-button text circle size="small" @click="onDrawLineClick" v-if="!is_npm_env">
-          <el-icon
-            title="连线编辑模式"
-            :size="20"
-            :class="drawline_selected ? 'icon-selected' : ''">
-            <svg-analysis name="pen-line" />
-          </el-icon>
-        </el-button>
-      </div>
-      <div class="flex justify-center items-center">
-        <el-tag v-if="headerPanelProps.realTimeData.show" size="small">{{
-          headerPanelProps.realTimeData.text
-        }}</el-tag>
-      </div>
-      <div class="flex items-center mr-20px">
-        <el-button text circle size="small" @click="emits('onReturnClick')">
-          <el-icon title="返回" :size="20">
-            <svg-analysis name="return" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button
-          text
-          circle
-          size="small"
-          :loading="headerPanelProps.saveLoading"
-          :disabled="headerPanelProps.saveLoading"
-          @click="emits('onSaveClick')">
-          <el-icon title="保存" :size="20">
-            <svg-analysis name="save" />
-          </el-icon>
-        </el-button>
-        <el-divider v-if="headerPanelProps.useThumbnail" direction="vertical" />
-        <el-button
-          v-if="headerPanelProps.useThumbnail"
-          text
-          circle
-          size="small"
-          @click="emits('onThumbnailClick')">
-          <el-icon title="生成缩略图" :size="20">
-            <svg-analysis name="thumbnail" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="emits('onPreviewClick')">
-          <el-icon title="预览" :size="20">
-            <svg-analysis name="preview" />
-          </el-icon>
-        </el-button>
+        <tool-button label="组合" :disabled="!headerPanelProps.groupEnabled" @click="onGroupClick">
+          <svg-analysis name="group" />
+        </tool-button>
+        <tool-button
+          label="解组"
+          :disabled="!headerPanelProps.unGroupEnabled"
+          @click="onUngroupClick">
+          <svg-analysis name="ungroup" />
+        </tool-button>
+        <tool-button
+          v-if="!is_npm_env"
+          label="连线"
+          :active="drawline_selected"
+          @click="onDrawLineClick">
+          <svg-analysis name="pen-line" />
+        </tool-button>
       </div>
     </div>
-    <div class="flex items-center justify-between w-200px">
+
+    <section class="editor-header__actions">
+      <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>
+      <tool-button label="返回" @click="emits('onReturnClick')">
+        <svg-analysis name="return" />
+      </tool-button>
+      <tool-button
+        v-if="headerPanelProps.useThumbnail"
+        label="缩略图"
+        @click="emits('onThumbnailClick')">
+        <svg-analysis name="thumbnail" />
+      </tool-button>
+      <tool-button label="预览" @click="emits('onPreviewClick')">
+        <svg-analysis name="preview" />
+      </tool-button>
       <el-button
-        text
-        circle
-        size="small"
-        @click="emits('update:rightAside', !headerPanelProps.rightAside)">
-        <el-icon :size="20" style="cursor: pointer">
-          <svg-analysis v-if="headerPanelProps.rightAside" name="menu-unfold" />
-          <svg-analysis v-else name="menu-fold" />
-        </el-icon>
+        class="save-button"
+        type="primary"
+        :loading="headerPanelProps.saveLoading"
+        :disabled="headerPanelProps.saveLoading"
+        @click="emits('onSaveClick')">
+        <el-icon v-if="!headerPanelProps.saveLoading"><svg-analysis name="save" /></el-icon>
+        {{ headerPanelProps.saveLoading ? '保存中' : '保存' }}
       </el-button>
-      <div class="flex items-center">
-        <el-button text circle size="small" @click="onHelpClick">
-          <el-icon title="帮助" :size="20">
-            <svg-analysis name="help" />
+
+      <div class="utility-actions">
+        <el-button text circle @click="emits('update:rightAside', !headerPanelProps.rightAside)">
+          <el-icon>
+            <svg-analysis :name="headerPanelProps.rightAside ? 'menu-unfold' : 'menu-fold'" />
           </el-icon>
         </el-button>
-        <el-button text circle size="small" @click="toggle">
-          <el-icon title="全屏" :size="20">
+        <el-button text circle @click="toggle">
+          <el-icon>
             <svg-analysis :name="isFullscreen ? 'exit-full-screen' : 'full-screen'" />
           </el-icon>
         </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="changeLockState">
-          <el-icon :title="headerPanelProps.lockState ? '已锁定' : '已解锁'" :size="20">
+        <el-button
+          text
+          circle
+          :class="{ 'utility-actions__active': headerPanelProps.lockState }"
+          @click="changeLockState">
+          <el-icon>
             <svg-analysis :name="headerPanelProps.lockState ? 'lock' : 'unlock'" />
           </el-icon>
         </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" class="mr-10px">
-          <el-icon
-            :title="isDark ? '切换到日间模式' : '切换到夜间模式'"
-            :size="20"
-            @click="toggleDark()">
-            <svg-analysis :name="isDark ? 'light' : 'dark'" />
-          </el-icon>
+        <el-button text circle @click="toggleDark()">
+          <el-icon><svg-analysis :name="isDark ? 'light' : 'dark'" /></el-icon>
         </el-button>
       </div>
-    </div>
+    </section>
   </div>
 </template>
+
 <script setup lang="ts">
-import { useDark, useToggle, useFullscreen } from '@vueuse/core'
-import {
-  ElIcon,
-  ElDivider,
-  ElPopover,
-  ElButton,
-  ElButtonGroup,
-  ElImage,
-  ElText,
-  ElTag
-} from 'element-plus'
+import deepOilLogo from '@/assets/imgs/logo.png'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import type { IRealTimeData } from '@/components/mt-edit/store/types'
-import deepOilLogo from '@/assets/imgs/logo.png'
-import { getSystemShortcutKeyName } from '@/components/mt-edit/utils'
-import { ref } from 'vue'
+import { useDark, useFullscreen, useToggle } from '@vueuse/core'
+import { ElButton, ElIcon, ElImage, ElPopover, ElTag } from 'element-plus'
+import { defineComponent, h, ref } from 'vue'
+
 type HeaderPanelProps = {
+  title?: string
   leftAside: boolean
   rightAside: boolean
-  selectedItemsId: string[] //已选中组件的id
+  selectedItemsId: string[]
   groupEnabled: boolean
   unGroupEnabled: boolean
   alignEnabled: boolean
@@ -280,7 +192,50 @@ type HeaderPanelProps = {
   useThumbnail?: boolean
   saveLoading?: boolean
 }
+
+const ToolButton = defineComponent({
+  name: 'ToolButton',
+  props: {
+    label: { type: String, required: true },
+    disabled: Boolean,
+    active: Boolean,
+    danger: Boolean,
+    compact: Boolean
+  },
+  emits: ['click'],
+  setup(props, { slots, emit }) {
+    return () =>
+      h(
+        'span',
+        { class: 'tool-button-wrap' },
+        h(
+          ElButton,
+          {
+            text: true,
+            disabled: props.disabled,
+            class: [
+              'tool-button',
+              {
+                'tool-button--active': props.active,
+                'tool-button--danger': props.danger,
+                'tool-button--compact': props.compact
+              }
+            ],
+            onClick: () => emit('click')
+          },
+          {
+            default: () => [
+              h(ElIcon, { size: props.compact ? 18 : 19 }, slots.default),
+              h('span', { class: 'tool-button__label' }, props.label)
+            ]
+          }
+        )
+      )
+  }
+})
+
 const headerPanelProps = withDefaults(defineProps<HeaderPanelProps>(), {
+  title: '未命名项目',
   leftAside: true,
   rightAside: true,
   useThumbnail: false,
@@ -297,7 +252,6 @@ const emits = defineEmits([
   'onTreeClick',
   'alignSelected',
   'update:lockState',
-  'onHelpClick',
   'onRedoClick',
   'onUndoClick',
   'onImportClick',
@@ -307,29 +261,16 @@ const emits = defineEmits([
   'onDrawLineClick',
   'onThumbnailClick'
 ])
-const isDark = useDark({
-  selector: '#mt-edit'
-})
+const isDark = useDark({ selector: '#mt-edit' })
 const { isFullscreen, toggle } = useFullscreen()
 const toggleDark = useToggle(isDark)
 const drawline_selected = ref(false)
 const is_npm_env = ref(import.meta.env.MODE === 'npm')
-const systemShortcutKey = getSystemShortcutKeyName()
-const onGroupClick = () => {
-  emits('onGroupClick')
-}
-const onUngroupClick = () => {
-  emits('onUngroupClick')
-}
-const onDeleteClick = () => {
-  emits('onDeleteClick')
-}
-const onExportClick = () => {
-  emits('onExportClick')
-}
-const onTreeClick = () => {
-  emits('onTreeClick')
-}
+const onGroupClick = () => emits('onGroupClick')
+const onUngroupClick = () => emits('onUngroupClick')
+const onDeleteClick = () => emits('onDeleteClick')
+const onExportClick = () => emits('onExportClick')
+const onTreeClick = () => emits('onTreeClick')
 const alignSelected = (
   type:
     | 'left'
@@ -340,52 +281,263 @@ const alignSelected = (
     | 'bottom'
     | 'horizontal-distribution'
     | 'vertical-distribution'
-) => {
-  emits('alignSelected', type)
-}
-const changeLockState = () => {
-  emits('update:lockState', !headerPanelProps.lockState)
-}
-const onHelpClick = () => {
-  emits('onHelpClick')
-}
-const onImportClick = () => {
-  emits('onImportClick')
-}
+) => emits('alignSelected', type)
+const changeLockState = () => emits('update:lockState', !headerPanelProps.lockState)
+const onImportClick = () => emits('onImportClick')
 const onDrawLineClick = () => {
   drawline_selected.value = !drawline_selected.value
   emits('onDrawLineClick', drawline_selected.value)
 }
 </script>
+
 <style scoped>
-.editor-brand {
+.editor-header {
+  display: flex;
+  width: 100%;
+  height: 54px;
+  min-width: 960px;
+  padding: 5px 18px;
+  color: #27364a;
+  background: rgb(255 255 255 / 96%);
+  border-bottom: 1px solid #dce2ea;
+  box-shadow: 0 2px 10px rgb(15 23 42 / 5%);
+  box-sizing: border-box;
+  align-items: stretch;
+}
+
+.editor-header__brand,
+.editor-header__actions,
+.tool-group,
+.utility-actions {
   display: flex;
   align-items: center;
-  gap: 8px;
-  height: 36px;
-  padding-left: 12px;
+}
+
+.editor-header__brand {
+  flex: 0 0 240px;
+  gap: 14px;
+  padding-right: 18px;
+  border-right: 1px solid #dfe4eb;
 }
 
 .editor-brand__logo {
-  width: 28px;
-  height: 28px;
-  flex: 0 0 28px;
-  border-radius: 6px;
+  width: 32px;
+  height: 32px;
   overflow: hidden;
-  box-shadow: 0 0 0 1px rgba(18, 120, 255, 0.16);
+  border-radius: 9px;
+  box-shadow:
+    0 0 0 1px rgb(64 158 255 / 16%),
+    0 4px 12px rgb(64 158 255 / 12%);
+  flex: 0 0 32px;
+}
+
+.editor-brand__text {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 5px;
 }
 
-.editor-brand__title {
+.editor-brand__name {
   font-size: 15px;
   font-weight: 700;
-  color: #1f2937;
-  letter-spacing: 0;
   line-height: 1;
+  color: #101828;
 }
-</style>
-<style scoped>
-.icon-selected {
-  color: #409eff;
-  background-color: #ecf5ff;
+
+.editor-brand__project {
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 1.2;
+  color: #667085;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.editor-header__toolbar {
+  display: flex;
+  min-width: 0;
+  overflow: auto hidden;
+  flex: 1;
+  align-items: stretch;
+  scrollbar-width: none;
+}
+
+.editor-header__toolbar::-webkit-scrollbar {
+  display: none;
+}
+
+.tool-group {
+  flex: 0 0 auto;
+  gap: 10px;
+  padding: 0 20px;
+  border-right: 1px solid #dfe4eb;
+}
+
+.editor-header__actions {
+  flex: 0 0 auto;
+  gap: 10px;
+  padding-left: 20px;
+}
+
+.selection-status {
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--el-color-primary);
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+:deep(.tool-button-wrap) {
+  display: inline-flex;
+}
+
+:deep(.tool-button) {
+  display: inline-flex;
+  width: auto;
+  height: auto;
+  min-width: 0;
+  padding: 8px 10px;
+  margin: 0;
+  color: #344054;
+  border-radius: 8px;
+}
+
+:deep(.tool-button > span) {
+  gap: 6px;
+}
+
+:deep(.tool-button:hover) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+:deep(.tool-button.is-disabled) {
+  color: #98a2b3;
+  background: transparent;
+  opacity: 1;
+}
+
+:deep(.tool-button--active) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  box-shadow: inset 0 0 0 1px var(--el-color-primary-light-7);
+}
+
+:deep(.tool-button--danger:not(.is-disabled):hover) {
+  color: var(--el-color-danger);
+  background: var(--el-color-danger-light-9);
+}
+
+:deep(.tool-button__label) {
+  margin-left: 0;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+:deep(.tool-button--compact) {
+  width: auto;
+  height: auto;
+}
+
+.save-button {
+  width: auto;
+  height: auto;
+  min-width: 0;
+  padding: 9px 16px;
+  margin-left: 6px;
+  font-weight: 600;
+  border-radius: 9px;
+  box-shadow: 0 5px 12px rgb(64 158 255 / 22%);
+}
+
+:deep(.save-button > span) {
+  gap: 7px;
+}
+
+.utility-actions {
+  padding-left: 14px;
+  margin-left: 10px;
+  border-left: 1px solid #dfe4eb;
+  gap: 6px;
+}
+
+.utility-actions :deep(.el-button) {
+  width: 36px;
+  height: 36px;
+  margin: 0;
+  color: #475467;
+}
+
+.utility-actions :deep(.el-button:hover),
+.utility-actions :deep(.utility-actions__active) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.align-panel__title {
+  margin-bottom: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #273244;
+}
+
+.align-panel__actions {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.align-panel__actions :deep(.tool-button-wrap),
+.align-panel__actions :deep(.tool-button) {
+  width: 100%;
+}
+
+.dark .editor-header {
+  color: #cbd5e1;
+  background: rgb(24 31 43 / 97%);
+  border-bottom-color: #30394a;
+  box-shadow: 0 2px 12px rgb(0 0 0 / 22%);
+}
+
+.dark .editor-header__brand,
+.dark .tool-group,
+.dark .utility-actions {
+  border-color: #30394a;
+}
+
+.dark .editor-brand__name,
+.dark .align-panel__title {
+  color: #f1f5f9;
+}
+
+.dark .editor-brand__project {
+  color: #a7b2c3;
+}
+
+.dark :deep(.tool-button) {
+  color: #d2d9e4;
+}
+
+.dark :deep(.tool-button.is-disabled) {
+  color: #7f8b9d;
+  opacity: 1;
+}
+
+.dark .utility-actions :deep(.el-button) {
+  color: #bdc7d5;
+}
+
+.dark :deep(.tool-button:hover),
+.dark :deep(.tool-button--active),
+.dark .utility-actions :deep(.el-button:hover),
+.dark .utility-actions :deep(.utility-actions__active) {
+  color: var(--el-color-primary-light-3);
+  background: rgb(64 158 255 / 14%);
 }
 </style>

+ 892 - 139
src/components/mt-edit/components/layout/left-aside/index.vue

@@ -1,157 +1,241 @@
 <template>
-  <div id="mt-left-aside" class="pt-10px h-1/1 box-border p-x-10px">
-    <el-input v-model="search_str" class="pb-10px pr-10px" placeholder="请输入关键字进行搜索" />
-    <div class="h-85/100">
-      <el-scrollbar class="pr-10px" :view-style="{ height: '100%' }">
-        <el-collapse v-model="active_names">
+  <div id="mt-left-aside" class="component-library">
+    <header class="component-library__header">
+      <div>
+        <h2>组件库</h2>
+        <p>拖拽组件到画布中使用</p>
+      </div>
+      <span class="component-library__count">{{ filtered_component_count }} 个</span>
+    </header>
+
+    <el-input
+      v-model="search_str"
+      class="component-library__search"
+      clearable
+      placeholder="搜索组件名称">
+      <template #prefix>
+        <el-icon><Search /></el-icon>
+      </template>
+    </el-input>
+
+    <main class="component-library__content">
+      <el-scrollbar :view-style="{ minHeight: '100%' }">
+        <el-collapse v-if="checked_keys.length" v-model="active_names" class="category-list">
           <el-collapse-item
             v-for="config_item_key in checked_keys"
             :key="config_item_key"
-            :title="config_item_key"
             :name="config_item_key">
-            <div class="flex flex-wrap">
-              <div
+            <template #title>
+              <div class="category-title">
+                <span class="category-title__icon"><CollectionTag /></span>
+                <span class="category-title__name">{{ config_item_key }}</span>
+                <span class="category-title__count">
+                  {{ getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key)).length }}
+                </span>
+              </div>
+            </template>
+
+            <div
+              v-if="getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key)).length"
+              class="component-grid">
+              <article
+                v-for="item in getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key))"
+                :key="item.id"
                 draggable="true"
+                class="component-card"
                 @dragstart="onDragStart(config_item_key, item.id)"
-                @touchstart.passive="onDragStart(config_item_key, item.id)"
-                class="w-40px h-40px"
-                v-for="(item, index) in getFilteritems(
-                  leftAsideProps.leftAsideConfig.get(config_item_key)
-                )"
-                :key="item.id">
-                <el-tooltip
-                  v-model:visible="is_show_tooltip[`${config_item_key}${item.id}`]"
-                  placement="right"
-                  :width="200"
-                  :effect="isDark ? 'dark' : 'light'"
-                  :show-arrow="false"
-                  :hide-after="0"
-                  trigger="hover"
-                  :enterable="false"
-                  :offset="getOffset(index + 1)">
+                @touchstart.passive="onDragStart(config_item_key, item.id)">
+                <div class="component-card__preview">
                   <el-image
                     draggable="false"
-                    class="w-30px h-30px select-none"
+                    fit="contain"
+                    class="component-card__image"
                     :class="isDark ? 'bg-amber-50' : ''"
                     :src="item.thumbnail" />
-                  <template #content>
-                    <div class="flex justify-center items-center">
-                      <div class="flex flex-col">
-                        <el-text>{{ item.title }}</el-text>
-                        <el-image
-                          class="w-100px h-100px pt-5px"
-                          :class="isDark ? 'bg-amber-50' : ''"
-                          :src="item.thumbnail" />
-                      </div>
-                    </div>
-                  </template>
+                </div>
+                <el-tooltip
+                  :content="item.title"
+                  placement="top"
+                  :show-after="300"
+                  :effect="isDark ? 'dark' : 'light'">
+                  <span class="component-card__name">{{ item.title }}</span>
                 </el-tooltip>
-              </div>
+              </article>
             </div>
+            <el-empty v-else :image-size="44" description="暂无匹配组件" />
           </el-collapse-item>
         </el-collapse>
+        <el-empty v-else :image-size="64" description="暂无可用分类" />
       </el-scrollbar>
-    </div>
-    <div class="h-[calc(10%-1px)] flex justify-center items-center ct-border">
-      <el-button class="w-80/100" @click="onManageClick">管理</el-button>
-    </div>
-    <el-dialog v-model="manage_dialog_visiable" title="图库管理" width="80%" destroy-on-close>
-      <div class="flex">
-        <div>
+    </main>
+
+    <footer class="component-library__footer">
+      <el-button type="primary" plain @click="onManageClick">
+        <el-icon><Setting /></el-icon>
+        组件管理
+      </el-button>
+      <span>管理分类与本地素材</span>
+    </footer>
+
+    <el-dialog
+      v-model="manage_dialog_visiable"
+      width="960px"
+      class="component-manage-dialog"
+      align-center
+      destroy-on-close
+      @opened="onManageOpened">
+      <template #header>
+        <div class="manage-dialog__header">
+          <span class="manage-dialog__header-icon"><CollectionTag /></span>
           <div>
-            <div class="flex justify-center">
-              <el-checkbox v-model="check_all" :indeterminate="is_indeterminate">全选</el-checkbox>
-            </div>
-            <el-scrollbar height="50vh">
-              <el-tree
-                ref="treeRef"
-                :data="classify_list"
-                :highlight-current="true"
-                show-checkbox
-                @check-change="handleCheckChange"
-                node-key="label"
-                :default-checked-keys="checked_keys"
-                @node-click="onNodeClick" />
-            </el-scrollbar>
-            <el-upload
-              ref="uploadRef"
-              class="w-24px h-24px"
-              v-model:file-list="up_img_list"
-              :auto-upload="false"
-              :limit="1"
-              :show-file-list="false"
-              :on-change="onUpLoadChange"
-              accept="image/*">
-              <el-button type="primary">本地上传</el-button>
-            </el-upload>
+            <h2>组件管理</h2>
+            <p>选择侧栏中显示的分类,并管理本地上传素材</p>
           </div>
         </div>
+      </template>
 
-        <el-divider direction="vertical" class="h-50vh ml-40px" />
-        <div v-if="selected_node_key">
-          <el-scrollbar height="50vh">
-            <div class="flex flex-wrap">
-              <div
-                v-for="item in leftAsideProps.leftAsideConfig.get(selected_node_key)"
+      <div class="manage-dialog__layout">
+        <aside class="manage-categories">
+          <div class="manage-categories__header">
+            <div>
+              <strong>组件分类</strong>
+              <span>{{ checked_keys.length }}/{{ classify_list.length }} 个已启用</span>
+            </div>
+            <el-checkbox v-model="check_all" :indeterminate="is_indeterminate">全选</el-checkbox>
+          </div>
+          <el-scrollbar class="manage-categories__scrollbar">
+            <el-tree
+              ref="treeRef"
+              class="manage-category-tree"
+              :data="classify_list"
+              :highlight-current="true"
+              :check-on-click-node="false"
+              :check-on-click-leaf="false"
+              show-checkbox
+              node-key="label"
+              :current-node-key="selected_node_key"
+              :default-checked-keys="checked_keys"
+              @check-change="handleCheckChange"
+              @node-click="onNodeClick">
+              <template #default="{ data }">
+                <div class="manage-category-node">
+                  <span>{{ data.label }}</span>
+                  <span>{{ leftAsideProps.leftAsideConfig.get(data.label)?.length || 0 }}</span>
+                </div>
+              </template>
+            </el-tree>
+          </el-scrollbar>
+        </aside>
+
+        <main class="manage-components">
+          <header class="manage-components__header">
+            <div>
+              <h3>{{ selected_node_key || '请选择分类' }}</h3>
+              <p>共 {{ selected_category_items.length }} 个组件</p>
+            </div>
+            <div class="manage-components__actions">
+              <el-input v-model="manage_search_str" clearable placeholder="搜索当前分类">
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+              </el-input>
+              <el-upload
+                v-if="selected_node_key === '本地文件'"
+                ref="uploadRef"
+                v-model:file-list="up_img_list"
+                :auto-upload="false"
+                :limit="1"
+                :show-file-list="false"
+                :on-change="onUpLoadChange"
+                accept="image/*">
+                <el-button type="primary">
+                  <el-icon><UploadFilled /></el-icon>
+                  上传素材
+                </el-button>
+              </el-upload>
+            </div>
+          </header>
+
+          <el-scrollbar class="manage-components__scrollbar">
+            <div v-if="managed_category_items.length" class="manage-component-grid">
+              <article
+                v-for="item in managed_category_items"
                 :key="item.id"
-                class="w-160px h-160px flex flex-wrap justify-center items-center cursor-pointer relative"
-                @mouseenter="show_del_local_file = item.id"
-                @mouseleave="show_del_local_file = null">
-                <el-tooltip
-                  :effect="isDark ? 'dark' : 'light'"
-                  :content="item.title"
-                  placement="top">
-                  <div>
-                    <el-image
-                      class="w-60px h-60px"
-                      :class="isDark ? 'bg-amber-50' : ''"
-                      :src="item.thumbnail" />
-                    <div class="w-60px h-60px flex justify-center items-center">
-                      <el-text truncated>{{ item.title }}</el-text>
-                    </div>
-                    <div
-                      v-if="selected_node_key == '本地文件' && show_del_local_file == item.id"
-                      class="absolute w-160px h-160px left-0 top-0 opacity-80 bg-light-300 flex justify-center items-center">
-                      <el-button type="danger" @click="onDelLocalFile(item)">删除</el-button>
-                    </div>
-                  </div>
+                class="manage-component-card">
+                <div class="manage-component-card__preview">
+                  <el-image
+                    fit="contain"
+                    class="manage-component-card__image"
+                    :class="isDark ? 'bg-amber-50' : ''"
+                    :src="item.thumbnail" />
+                </div>
+                <el-tooltip :content="item.title" placement="top" :show-after="300">
+                  <span class="manage-component-card__name">{{ item.title }}</span>
                 </el-tooltip>
-              </div>
+                <el-popconfirm
+                  v-if="selected_node_key === '本地文件'"
+                  width="210"
+                  title="确认删除这个本地素材吗?"
+                  confirm-button-text="删除"
+                  cancel-button-text="取消"
+                  @confirm="onDelLocalFile(item)">
+                  <template #reference>
+                    <el-button
+                      class="manage-component-card__delete"
+                      type="danger"
+                      text
+                      circle
+                      @click.stop>
+                      <el-icon><Delete /></el-icon>
+                    </el-button>
+                  </template>
+                </el-popconfirm>
+              </article>
             </div>
+            <el-empty v-else :image-size="84" :description="manage_empty_description" />
           </el-scrollbar>
-        </div>
+        </main>
       </div>
+
+      <template #footer>
+        <div class="manage-dialog__footer">
+          <span>分类显示设置会自动保存</span>
+          <el-button type="primary" @click="manage_dialog_visiable = false">完成</el-button>
+        </div>
+      </template>
     </el-dialog>
   </div>
 </template>
 <script setup lang="ts">
-import { computed, reactive, ref } from 'vue'
+import { globalStore } from '@/components/mt-edit/store/global'
+import type {
+  ILeftAsideConfig,
+  ILeftAsideConfigItem,
+  ILeftAsideConfigItemPublic
+} from '@/components/mt-edit/store/types'
+import { blobToBase64 } from '@/components/mt-edit/utils'
+import { CollectionTag, Delete, Search, Setting, UploadFilled } from '@element-plus/icons-vue'
+import { useDark, useLocalStorage } from '@vueuse/core'
 import {
-  ElInput,
+  ElButton,
+  ElCheckbox,
   ElCollapse,
   ElCollapseItem,
-  ElButton,
-  ElScrollbar,
-  ElImage,
-  ElTooltip,
-  ElText,
   ElDialog,
-  ElCheckbox,
-  ElDivider,
+  ElEmpty,
+  ElIcon,
+  ElImage,
+  ElInput,
+  ElMessage,
+  ElPopconfirm,
+  ElScrollbar,
   ElTree,
+  ElTooltip,
   ElUpload,
-  type UploadUserFile,
-  ElMessage,
-  type UploadFile
+  type UploadFile,
+  type UploadUserFile
 } from 'element-plus'
-import { useDark, useLocalStorage } from '@vueuse/core'
-import type {
-  ILeftAsideConfig,
-  ILeftAsideConfigItem,
-  ILeftAsideConfigItemPublic
-} from '@/components/mt-edit/store/types'
-import { globalStore } from '@/components/mt-edit/store/global'
-import { blobToBase64 } from '@/components/mt-edit/utils'
+import { computed, nextTick, ref, watch } from 'vue'
 type LeftAsideProps = {
   leftAsideConfig: ILeftAsideConfig
 }
@@ -168,12 +252,11 @@ const disable_classify = useLocalStorage<string[]>('mt-disable-classify', [])
 const local_file = useLocalStorage<ILeftAsideConfigItem[]>('mt-local-file', [])
 leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
 const treeRef = ref()
-const is_show_tooltip: Record<string, boolean> = reactive({})
-const active_names = ref([])
-const search_str = ref()
+const active_names = ref<string[]>([])
+const search_str = ref('')
 const manage_dialog_visiable = ref(false)
 const up_img_list = ref<UploadUserFile[]>([])
-const show_del_local_file = ref<null | string>(null)
+const manage_search_str = ref('')
 const classify_list = computed(() =>
   [...leftAsideProps.leftAsideConfig.keys()].map((m) => {
     return { label: m }
@@ -182,34 +265,56 @@ const classify_list = computed(() =>
 const checked_keys = ref<string[]>(
   classify_list.value.filter((f) => !disable_classify.value.includes(f.label)).map((m) => m.label)
 )
-const selected_node_key = ref()
+const filtered_component_count = computed(() =>
+  checked_keys.value.reduce(
+    (count, key) => count + getFilteritems(leftAsideProps.leftAsideConfig.get(key)).length,
+    0
+  )
+)
+const selected_node_key = ref<string>()
+const selected_category_items = computed<ILeftAsideConfigItemPublic[]>(() => {
+  if (!selected_node_key.value) {
+    return []
+  }
+  return leftAsideProps.leftAsideConfig.get(selected_node_key.value) || []
+})
+const managed_category_items = computed(() => {
+  const keyword = manage_search_str.value.trim().toLocaleLowerCase()
+  if (!keyword) {
+    return selected_category_items.value
+  }
+  return selected_category_items.value.filter((item) =>
+    item.title.toLocaleLowerCase().includes(keyword)
+  )
+})
+const manage_empty_description = computed(() => {
+  if (!selected_node_key.value) {
+    return '请先选择一个组件分类'
+  }
+  return manage_search_str.value.trim() ? '没有找到匹配的组件' : '当前分类暂无组件'
+})
 const check_all = computed({
   get: () => {
     return classify_list.value.length == checked_keys.value.length
   },
   set: (val) => {
-    if (val) {
-      checked_keys.value = classify_list.value.map((m) => m.label)
-    } else {
-      checked_keys.value = []
-      treeRef.value?.setCheckedNodes([])
-    }
+    const keys = val ? classify_list.value.map((m) => m.label) : []
+    checked_keys.value = keys
+    treeRef.value?.setCheckedKeys(keys)
   }
 })
 const is_indeterminate = computed(() => {
   return checked_keys.value.length > 0 && checked_keys.value.length < classify_list.value.length
 })
-const getOffset = (index: number) => {
-  return index % 4 == 0 ? 40 : index % 4 == 3 ? 80 : index % 4 == 2 ? 120 : 160
-}
 const getFilteritems = (
   arr: ILeftAsideConfigItemPublic[] | undefined
 ): ILeftAsideConfigItemPublic[] => {
   if (!arr) {
     return []
   }
-  if (search_str.value) {
-    return arr.filter((f) => f.title.includes(search_str.value))
+  const keyword = search_str.value.trim()
+  if (keyword) {
+    return arr.filter((f) => f.title.toLocaleLowerCase().includes(keyword.toLocaleLowerCase()))
   }
   return arr
 }
@@ -218,7 +323,6 @@ const onDragStart = (config_item_key: string, item_id: string) => {
     console.error('拖拽初始化失败', config_item_key, item_id)
     return
   }
-  is_show_tooltip[`${config_item_key}${item_id}`] = false
   globalStore.setIntention('create')
   globalStore.setCreateItemInfo({
     config_key: config_item_key,
@@ -226,9 +330,31 @@ const onDragStart = (config_item_key: string, item_id: string) => {
   })
 }
 const onManageClick = () => {
+  const selectedExists = classify_list.value.some((item) => item.label === selected_node_key.value)
+  if (!selectedExists) {
+    selected_node_key.value =
+      classify_list.value.find((item) => item.label === '本地文件')?.label ||
+      classify_list.value[0]?.label
+  }
+  manage_search_str.value = ''
   manage_dialog_visiable.value = true
 }
-const handleCheckChange = (data: { label: string }, checked: boolean, indeterminate: boolean) => {
+const onManageOpened = () => {
+  nextTick(() => {
+    treeRef.value?.setCurrentKey(selected_node_key.value)
+    treeRef.value?.setCheckedKeys(checked_keys.value)
+  })
+}
+
+watch(search_str, (keyword) => {
+  if (!keyword.trim()) {
+    return
+  }
+  active_names.value = checked_keys.value.filter(
+    (key) => getFilteritems(leftAsideProps.leftAsideConfig.get(key)).length > 0
+  )
+})
+const handleCheckChange = (data: { label: string }, checked: boolean) => {
   if (checked && !checked_keys.value.includes(data.label)) {
     checked_keys.value.push(data.label)
   } else if (!checked) {
@@ -240,6 +366,7 @@ const handleCheckChange = (data: { label: string }, checked: boolean, indetermin
 }
 const onNodeClick = ({ label }: { label: string }) => {
   selected_node_key.value = label
+  manage_search_str.value = ''
 }
 const onUpLoadChange = (e: UploadFile) => {
   if (!e.raw!.type.includes('image/')) {
@@ -279,6 +406,7 @@ const onUpLoadChange = (e: UploadFile) => {
     up_img_list.value = []
     selected_node_key.value = '本地文件'
     treeRef.value?.setCurrentKey('本地文件')
+    ElMessage.success('素材上传成功')
   })
 }
 const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
@@ -287,11 +415,636 @@ const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
     local_file.value.splice(find_index, 1)
   }
   leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
+  ElMessage.success('本地素材已删除')
 }
 </script>
+<style scoped>
+.component-library {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 14px 12px 0;
+  background: rgb(255 255 255 / 96%);
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.component-library__header {
+  display: flex;
+  padding: 0 4px;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.component-library__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.component-library__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.component-library__count {
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 18px;
+  color: var(--el-color-primary);
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+.component-library__search {
+  margin: 13px 0 12px;
+}
+
+.component-library__search :deep(.el-input__wrapper) {
+  padding: 0 11px;
+  background: #fff;
+  border-radius: 9px;
+  box-shadow: 0 0 0 1px #dbe1ea inset;
+}
+
+.component-library__search :deep(.el-input__wrapper:hover),
+.component-library__search :deep(.el-input__wrapper.is-focus) {
+  box-shadow: 0 0 0 1px var(--el-color-primary) inset;
+}
+
+.component-library__content {
+  min-height: 0;
+  flex: 1;
+}
+
+.component-library__content :deep(.el-scrollbar__view) {
+  padding-right: 3px;
+}
+
+.category-list {
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item) {
+  margin-bottom: 8px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+  transition:
+    border-color 0.2s ease,
+    box-shadow 0.2s ease;
+}
+
+.category-list :deep(.el-collapse-item:hover),
+.category-list :deep(.el-collapse-item.is-active) {
+  border-color: var(--el-color-primary-light-7);
+  box-shadow: 0 4px 12px rgb(31 45 61 / 6%);
+}
+
+.category-list :deep(.el-collapse-item__header) {
+  height: 44px;
+  padding: 0 11px;
+  background: transparent;
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item__arrow) {
+  margin-left: 8px;
+  color: #7d899b;
+}
+
+.category-list :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item__content) {
+  padding: 0 10px 11px;
+}
+
+.category-title {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+  gap: 8px;
+}
+
+.category-title__icon {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 7px;
+  align-items: center;
+  justify-content: center;
+}
+
+.category-title__icon svg {
+  width: 15px;
+  height: 15px;
+}
+
+.category-title__name {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.category-title__count {
+  min-width: 20px;
+  padding: 1px 6px;
+  margin-left: auto;
+  font-size: 11px;
+  line-height: 18px;
+  color: #667085;
+  text-align: center;
+  background: #f1f4f8;
+  border-radius: 9px;
+}
+
+.component-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 7px;
+  margin-top: 4px;
+}
+
+.component-card {
+  min-width: 0;
+  padding: 5px;
+  cursor: grab;
+  background: #f8fafc;
+  border: 1px solid #e4e9f0;
+  border-radius: 9px;
+  transition:
+    border-color 0.18s ease,
+    background 0.18s ease,
+    box-shadow 0.18s ease,
+    transform 0.18s ease;
+}
+
+.component-card:hover {
+  background: #fff;
+  border-color: var(--el-color-primary-light-5);
+  transform: translateY(-1px);
+  box-shadow: 0 5px 14px rgb(64 158 255 / 13%);
+}
+
+.component-card:active {
+  cursor: grabbing;
+  transform: translateY(0);
+}
+
+.component-card__preview {
+  display: flex;
+  height: 38px;
+  overflow: hidden;
+  background:
+    linear-gradient(45deg, #f2f5f8 25%, transparent 25%) 0 0 / 12px 12px,
+    linear-gradient(45deg, transparent 75%, #f2f5f8 75%) 0 0 / 12px 12px,
+    linear-gradient(45deg, transparent 75%, #f2f5f8 75%) 6px -6px / 12px 12px,
+    linear-gradient(45deg, #f2f5f8 25%, #fff 25%) 6px -6px / 12px 12px;
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.component-card__image {
+  width: 32px;
+  height: 32px;
+  user-select: none;
+}
+
+.component-card__name {
+  display: block;
+  margin-top: 4px;
+  overflow: hidden;
+  font-size: 11px;
+  font-weight: 500;
+  line-height: 18px;
+  color: #475467;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.category-list :deep(.el-empty) {
+  padding: 12px 0 6px;
+}
+
+.category-list :deep(.el-empty__description) {
+  margin-top: 4px;
+}
+
+.category-list :deep(.el-empty__description p) {
+  font-size: 12px;
+}
+
+.component-library__footer {
+  padding: 12px 2px 14px;
+  border-top: 1px solid #dfe4eb;
+}
+
+.component-library__footer :deep(.el-button) {
+  width: 100%;
+  height: 36px;
+  margin: 0;
+  font-weight: 600;
+  border-radius: 8px;
+}
+
+.component-library__footer :deep(.el-button > span) {
+  gap: 6px;
+}
+
+.component-library__footer > span {
+  display: block;
+  margin-top: 6px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-align: center;
+}
+
+.manage-dialog__header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.manage-dialog__header-icon {
+  display: inline-flex;
+  width: 38px;
+  height: 38px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+  align-items: center;
+  justify-content: center;
+}
+
+.manage-dialog__header-icon svg {
+  width: 20px;
+  height: 20px;
+}
+
+.manage-dialog__header h2,
+.manage-components__header h3 {
+  margin: 0;
+  color: #172033;
+}
+
+.manage-dialog__header h2 {
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+}
+
+.manage-dialog__header p,
+.manage-components__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.manage-dialog__layout {
+  display: grid;
+  height: min(560px, calc(100vh - 210px));
+  min-height: 420px;
+  grid-template-columns: 220px minmax(0, 1fr);
+  gap: 14px;
+}
+
+.manage-categories,
+.manage-components {
+  min-height: 0;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e1e7ef;
+  border-radius: 11px;
+}
+
+.manage-categories {
+  display: flex;
+  flex-direction: column;
+}
+
+.manage-categories__header,
+.manage-components__header {
+  display: flex;
+  border-bottom: 1px solid #e8ecf2;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.manage-categories__header {
+  min-height: 64px;
+  padding: 0 14px;
+}
+
+.manage-categories__header > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.manage-categories__header strong {
+  font-size: 13px;
+  color: #263245;
+}
+
+.manage-categories__header span {
+  font-size: 11px;
+  color: #8a95a6;
+}
+
+.manage-categories__header :deep(.el-checkbox) {
+  height: 28px;
+  margin-left: 8px;
+}
+
+.manage-categories__scrollbar,
+.manage-components__scrollbar {
+  min-height: 0;
+  flex: 1;
+}
+
+.manage-category-tree {
+  padding: 8px;
+  background: transparent;
+}
+
+.manage-category-tree :deep(.el-tree-node__content) {
+  height: 38px;
+  padding-right: 8px;
+  margin-bottom: 3px;
+  border-radius: 7px;
+}
+
+.manage-category-tree :deep(.el-tree-node__content:hover) {
+  background: #f4f7fb;
+}
+
+.manage-category-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.manage-category-node {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+}
+
+.manage-category-node > span:first-child {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.manage-category-node > span:last-child {
+  min-width: 22px;
+  padding: 0 6px;
+  margin-left: auto;
+  font-size: 10px;
+  line-height: 18px;
+  color: #718096;
+  text-align: center;
+  background: #eef2f7;
+  border-radius: 9px;
+}
+
+.manage-components {
+  display: flex;
+  flex-direction: column;
+}
+
+.manage-components__header {
+  min-height: 64px;
+  padding: 0 16px;
+  gap: 16px;
+}
+
+.manage-components__header h3 {
+  font-size: 15px;
+  font-weight: 700;
+  line-height: 21px;
+}
+
+.manage-components__actions {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 10px;
+}
+
+.manage-components__actions > :deep(.el-input) {
+  width: 190px;
+}
+
+.manage-components__actions :deep(.el-input__wrapper) {
+  border-radius: 7px;
+}
+
+.manage-components__actions :deep(.el-button > span) {
+  gap: 6px;
+}
+
+.manage-component-grid {
+  display: grid;
+  padding: 14px;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.manage-component-card {
+  position: relative;
+  min-width: 0;
+  padding: 8px;
+  background: #f8fafc;
+  border: 1px solid #e3e8f0;
+  border-radius: 9px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease,
+    transform 0.18s ease;
+}
+
+.manage-component-card:hover {
+  border-color: var(--el-color-primary-light-5);
+  transform: translateY(-1px);
+  box-shadow: 0 5px 14px rgb(31 45 61 / 8%);
+}
+
+.manage-component-card__preview {
+  display: flex;
+  height: 74px;
+  overflow: hidden;
+  background:
+    linear-gradient(45deg, #eef2f6 25%, transparent 25%) 0 0 / 14px 14px,
+    linear-gradient(45deg, transparent 75%, #eef2f6 75%) 0 0 / 14px 14px,
+    linear-gradient(45deg, transparent 75%, #eef2f6 75%) 7px -7px / 14px 14px,
+    linear-gradient(45deg, #eef2f6 25%, #fff 25%) 7px -7px / 14px 14px;
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.manage-component-card__image {
+  width: 64px;
+  height: 64px;
+}
+
+.manage-component-card__name {
+  display: block;
+  margin-top: 6px;
+  overflow: hidden;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 20px;
+  color: #475467;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.manage-component-card__delete {
+  position: absolute;
+  top: 5px;
+  right: 5px;
+  width: 26px;
+  height: 26px;
+  color: var(--el-color-danger);
+  background: rgb(255 255 255 / 92%);
+  opacity: 0;
+  transform: translateY(-2px);
+  box-shadow: 0 2px 7px rgb(31 45 61 / 12%);
+  transition:
+    opacity 0.18s ease,
+    transform 0.18s ease;
+}
+
+.manage-component-card:hover .manage-component-card__delete,
+.manage-component-card__delete:focus-visible {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.manage-components__scrollbar :deep(.el-empty) {
+  height: 100%;
+  padding: 30px 0;
+}
+
+.manage-dialog__footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.manage-dialog__footer > span {
+  font-size: 12px;
+  color: #8a95a6;
+}
+
+.manage-dialog__footer :deep(.el-button) {
+  min-width: 82px;
+}
+
+.dark .component-library {
+  background: rgb(24 31 43 / 97%);
+}
+
+.dark .component-library__header h2,
+.dark .category-title__name {
+  color: #edf2f8;
+}
+
+.dark .component-library__header p,
+.dark .component-library__footer > span {
+  color: #8d99aa;
+}
+
+.dark .component-library__search :deep(.el-input__wrapper),
+.dark .category-list :deep(.el-collapse-item) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .category-title__count {
+  color: #aeb8c7;
+  background: #2b3444;
+}
+
+.dark .component-card {
+  background: #252d3b;
+  border-color: #354052;
+}
+
+.dark .component-card:hover {
+  background: #2a3445;
+  border-color: var(--el-color-primary-dark-2);
+}
+
+.dark .component-card__name {
+  color: #cad3df;
+}
+
+.dark .component-library__footer {
+  border-color: #303949;
+}
+</style>
+
 <style>
-#mt-left-aside .el-collapse-item__header,
-#mt-left-aside .el-collapse-item__wrap {
-  background-color: transparent !important;
+.component-manage-dialog {
+  max-width: calc(100vw - 40px);
+  overflow: hidden;
+  border-radius: 14px;
+  box-shadow: 0 20px 60px rgb(15 23 42 / 20%);
+}
+
+.component-manage-dialog .el-dialog__header {
+  padding: 17px 20px;
+  margin: 0;
+  border-bottom: 1px solid #e4e9f0;
+}
+
+.component-manage-dialog .el-dialog__headerbtn {
+  top: 16px;
+  right: 16px;
+  width: 32px;
+  height: 32px;
+  border-radius: 7px;
+}
+
+.component-manage-dialog .el-dialog__headerbtn:hover {
+  background: #f1f4f8;
+}
+
+.component-manage-dialog .el-dialog__body {
+  padding: 14px 18px;
+  background: #f6f8fb;
+}
+
+.component-manage-dialog .el-dialog__footer {
+  padding: 12px 18px;
+  border-top: 1px solid #e4e9f0;
 }
 </style>

+ 201 - 22
src/components/mt-edit/components/layout/right-aside/index.vue

@@ -1,28 +1,44 @@
 <template>
-  <div id="mt-right-aside" class="px-4">
-    <select-item-setting
-      v-if="selected_item_json"
-      :key="selected_item_id"
-      :item-json="selected_item_json"
-      :done-json="globalStore.done_json"
-      @update:item-json="onUpdateItemJson"
-      @add-history="onAddHistory">
-      <template v-if="hasDeviceBindSlot" #deviceBind="{ item }">
-        <slot name="deviceBind" :item="item"></slot> </template
-    ></select-item-setting>
-    <page-setting
-      v-else
-      v-model:canvasCfg="globalStore.canvasCfg"
-      v-model:grid-cfg="globalStore.gridCfg" />
+  <div id="mt-right-aside" class="property-panel">
+    <header class="property-panel__header">
+      <div>
+        <h2>{{ selected_item_json ? '组件属性' : '页面设置' }}</h2>
+        <p>{{ selected_item_json ? '调整选中组件的样式与行为' : '配置画布和编辑辅助功能' }}</p>
+      </div>
+      <span v-if="selected_item_json" class="property-panel__selection">
+        {{ selected_item_json.title || '未命名' }}
+      </span>
+    </header>
+
+    <main class="property-panel__content">
+      <el-scrollbar :view-style="{ minHeight: '100%' }">
+        <select-item-setting
+          v-if="selected_item_json"
+          :key="selected_item_id"
+          :item-json="selected_item_json"
+          :done-json="globalStore.done_json"
+          @update:item-json="onUpdateItemJson"
+          @add-history="onAddHistory">
+          <template v-if="hasDeviceBindSlot" #deviceBind="{ item }">
+            <slot name="deviceBind" :item="item"></slot>
+          </template>
+        </select-item-setting>
+        <page-setting
+          v-else
+          v-model:canvasCfg="globalStore.canvasCfg"
+          v-model:grid-cfg="globalStore.gridCfg" />
+      </el-scrollbar>
+    </main>
   </div>
 </template>
 <script setup lang="ts">
+import { cacheStore } from '@/components/mt-edit/store/cache'
+import { globalStore } from '@/components/mt-edit/store/global'
+import type { IDoneJson } from '@/components/mt-edit/store/types'
+import { ElScrollbar } from 'element-plus'
 import { computed, useSlots } from 'vue'
 import PageSetting from './page-setting.vue'
 import SelectItemSetting from './select-item-setting.vue'
-import { globalStore } from '@/components/mt-edit/store/global'
-import { cacheStore } from '@/components/mt-edit/store/cache'
-import type { IDoneJson } from '@/components/mt-edit/store/types'
 const slots = useSlots()
 const selected_item_id = computed(() => globalStore.selected_items_id[0] || '')
 const selected_item_json = computed(() => {
@@ -47,9 +63,172 @@ const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind
 })
 </script>
-<style>
-#mt-right-aside .el-collapse-item__header,
-#mt-right-aside .el-collapse-item__wrap {
-  background-color: transparent !important;
+<style scoped>
+.property-panel {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 14px 12px 0;
+  background: rgb(255 255 255 / 96%);
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.property-panel__header {
+  display: flex;
+  padding: 0 4px 13px;
+  border-bottom: 1px solid #e3e8ef;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.property-panel__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.property-panel__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.property-panel__selection {
+  max-width: 100px;
+  padding: 3px 8px;
+  overflow: hidden;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 18px;
+  color: var(--el-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+.property-panel__content {
+  min-height: 0;
+  padding-top: 10px;
+  flex: 1;
+}
+
+.property-panel__content :deep(.el-scrollbar__view) {
+  padding: 0 3px 16px 0;
+}
+
+.property-panel__content :deep(.el-tabs__header) {
+  padding: 4px;
+  margin: 0 0 12px;
+  background: #f1f4f8;
+  border-radius: 9px;
+}
+
+.property-panel__content :deep(.el-tabs__nav-wrap::after),
+.property-panel__content :deep(.el-tabs__active-bar) {
+  display: none;
+}
+
+.property-panel__content :deep(.el-tabs__nav) {
+  width: 100%;
+}
+
+.property-panel__content :deep(.el-tabs__item) {
+  height: 32px;
+  padding: 0 12px;
+  color: #667085;
+  border-radius: 7px;
+  flex: 1;
+}
+
+.property-panel__content :deep(.el-tabs__item.is-active) {
+  font-weight: 600;
+  color: var(--el-color-primary);
+  background: #fff;
+  box-shadow: 0 2px 7px rgb(31 45 61 / 8%);
+}
+
+.property-panel__content :deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+
+.property-panel__content :deep(.el-form-item__label) {
+  font-size: 12px;
+  font-weight: 500;
+  color: #667085;
+}
+
+.property-panel__content :deep(.el-select),
+.property-panel__content :deep(.el-input),
+.property-panel__content :deep(.el-input-number) {
+  width: 100%;
+}
+
+.property-panel__content :deep(.el-collapse) {
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item) {
+  margin-bottom: 9px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+}
+
+.property-panel__content :deep(.el-collapse-item__header) {
+  height: 42px;
+  padding: 0 12px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #344054;
+  background: transparent;
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item__content) {
+  padding: 4px 12px 12px;
+}
+
+.dark .property-panel {
+  background: rgb(24 31 43 / 97%);
+}
+
+.dark .property-panel__header {
+  border-color: #303949;
+}
+
+.dark .property-panel__header h2 {
+  color: #edf2f8;
+}
+
+.dark .property-panel__header p {
+  color: #8d99aa;
+}
+
+.dark .property-panel__content :deep(.el-tabs__header) {
+  background: #202735;
+}
+
+.dark .property-panel__content :deep(.el-tabs__item.is-active) {
+  background: #2a3444;
+}
+
+.dark .property-panel__content :deep(.el-collapse-item) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .property-panel__content :deep(.el-collapse-item__header) {
+  color: #dbe3ed;
 }
 </style>

+ 92 - 8
src/components/mt-edit/components/layout/right-aside/page-setting.vue

@@ -1,7 +1,13 @@
 <template>
-  <el-tabs v-model="activeName" class="select-none">
-    <el-tab-pane label="页面" name="page">
-      <el-form label-width="70px" label-position="left">
+  <div class="page-setting select-none">
+    <section class="setting-card">
+      <header class="setting-card__header">
+        <div>
+          <h3>画布</h3>
+          <p>尺寸、缩放与背景</p>
+        </div>
+      </header>
+      <el-form label-width="78px" label-position="left" class="setting-form">
         <el-form-item label="画布尺寸" size="small">
           <el-select v-model="canvas_size" placeholder="请设置画布尺寸">
             <el-option-group label="自定义">
@@ -85,6 +91,17 @@
             </div>
           </el-upload>
         </el-form-item>
+      </el-form>
+    </section>
+
+    <section class="setting-card">
+      <header class="setting-card__header">
+        <div>
+          <h3>编辑辅助</h3>
+          <p>控制参考线、吸附与网格</p>
+        </div>
+      </header>
+      <el-form label-width="78px" label-position="left" class="setting-form">
         <el-form-item label="参考线" size="small">
           <el-switch v-model="canvas_guide" />
         </el-form-item>
@@ -101,14 +118,12 @@
           <el-input-number v-model="grid_size" :min="1" />
         </el-form-item>
       </el-form>
-    </el-tab-pane>
-  </el-tabs>
+    </section>
+  </div>
 </template>
 <script setup lang="ts">
 import type { IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '@/components/mt-edit/store/types'
 import {
-  ElTabs,
-  ElTabPane,
   ElForm,
   ElFormItem,
   ElInputNumber,
@@ -134,7 +149,6 @@ type RightAsideProps = {
 }
 const rightAsideProps = withDefaults(defineProps<RightAsideProps>(), {})
 const emits = defineEmits(['update:canvasCfg', 'update:gridCfg'])
-const activeName = ref('page')
 const canvasBgImgUploadRef = ref()
 const canvas_size = computed({
   get: () => {
@@ -348,3 +362,73 @@ const canvas_guide = computed({
   }
 })
 </script>
+
+<style scoped>
+.page-setting {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.setting-card {
+  padding: 13px 13px 2px;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+}
+
+.setting-card__header {
+  padding-bottom: 11px;
+  margin-bottom: 13px;
+  border-bottom: 1px solid #edf0f4;
+}
+
+.setting-card__header h3 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 20px;
+  color: #344054;
+}
+
+.setting-card__header p {
+  margin: 2px 0 0;
+  font-size: 11px;
+  line-height: 17px;
+  color: #98a2b3;
+}
+
+.setting-form :deep(.el-form-item) {
+  min-height: 32px;
+  margin-bottom: 12px;
+  align-items: center;
+}
+
+.setting-form :deep(.el-form-item__label) {
+  height: auto;
+  line-height: 18px;
+}
+
+.setting-form :deep(.el-form-item__content) {
+  min-width: 0;
+  justify-content: flex-end;
+}
+
+.setting-form :deep(.el-select),
+.setting-form :deep(.el-input-number) {
+  width: 100%;
+}
+
+.dark .setting-card {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .setting-card__header {
+  border-color: #303949;
+}
+
+.dark .setting-card__header h3 {
+  color: #dbe3ed;
+}
+</style>

+ 687 - 136
src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue

@@ -1,143 +1,253 @@
 <template>
-  <div>
-    <el-button type="primary" class="w-1/1" @click="onAddEvent">添加事件</el-button>
-    <el-form label-width="60px" label-position="left">
-      <el-collapse v-model="activeNames">
+  <div class="event-editor">
+    <header class="event-editor__header">
+      <div>
+        <strong>交互事件</strong>
+        <span>共 {{ event_list.length }} 个事件</span>
+      </div>
+      <el-button type="primary" size="small" @click="onAddEvent">
+        <el-icon><Plus /></el-icon>
+        添加事件
+      </el-button>
+    </header>
+
+    <el-empty
+      v-if="!event_list.length"
+      class="event-editor__empty"
+      :image-size="72"
+      description="还没有配置交互事件">
+      <el-button type="primary" plain size="small" @click="onAddEvent">创建第一个事件</el-button>
+    </el-empty>
+
+    <el-form v-else label-position="top" class="event-editor__form">
+      <el-collapse v-model="activeNames" class="event-list" accordion>
         <el-collapse-item
           v-for="(event_list_item, event_list_index) in event_list"
           :key="event_list_item.id"
           :name="event_list_item.id">
           <template #title>
-            <div class="flex justify-between items-center w-1/1">
-              <el-text>事件{{ event_list_index + 1 }}</el-text>
-              <el-popconfirm title="删除该事件?" @confirm="onDelEvent(event_list_index)">
+            <div class="event-card__title">
+              <span class="event-card__number">{{ event_list_index + 1 }}</span>
+              <div class="event-card__heading">
+                <strong>事件 {{ String(event_list_index + 1).padStart(2, '0') }}</strong>
+                <span>{{ getEventSummary(event_list_item) }}</span>
+              </div>
+              <el-button
+                class="event-card__icon-button"
+                text
+                circle
+                size="small"
+                aria-label="复制事件"
+                @click.stop="onCopyEvent(event_list_item, event_list_index)">
+                <el-icon><CopyDocument /></el-icon>
+              </el-button>
+              <el-popconfirm
+                width="190"
+                title="确认删除这个事件吗?"
+                confirm-button-text="删除"
+                cancel-button-text="取消"
+                @confirm="onDelEvent(event_list_index)">
                 <template #reference>
-                  <el-button text circle size="small" @click.stop>
-                    <el-icon title="删除" :class="``" :size="20">
-                      <svg-analysis name="delete" />
-                    </el-icon>
+                  <el-button
+                    class="event-card__icon-button event-card__delete"
+                    text
+                    circle
+                    size="small"
+                    aria-label="删除事件"
+                    @click.stop>
+                    <el-icon><Delete /></el-icon>
                   </el-button>
                 </template>
               </el-popconfirm>
             </div>
           </template>
-          <div>
-            <el-form-item label="事件类型" size="small" class="mt-10px">
-              <el-select placeholder="事件类型" v-model="event_list[event_list_index].type">
-                <el-option
-                  v-for="item in event_type"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-            </el-form-item>
-            <el-form-item label="事件行为" size="small">
-              <el-select
-                placeholder="事件行为"
+          <div class="event-card__body">
+            <section class="event-step">
+              <div class="event-step__heading">
+                <span>1</span>
+                <div>
+                  <strong>触发方式</strong>
+                  <p>用户执行什么操作时触发</p>
+                </div>
+              </div>
+              <el-radio-group
+                v-model="event_list[event_list_index].type"
+                class="event-type-options"
+                size="small">
+                <el-radio-button v-for="item in event_type" :key="item.value" :value="item.value">
+                  {{ item.label }}
+                </el-radio-button>
+              </el-radio-group>
+            </section>
+
+            <section class="event-step">
+              <div class="event-step__heading">
+                <span>2</span>
+                <div>
+                  <strong>执行动作</strong>
+                  <p>事件触发后执行的操作</p>
+                </div>
+              </div>
+              <el-radio-group
                 v-model="event_list[event_list_index].action"
+                class="event-action-options"
+                size="small"
                 @change="onEventActionChange(event_list_index)">
-                <el-option
-                  v-for="item in event_action"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-            </el-form-item>
-            <el-form-item
-              v-if="event_list_item.action == 'changeAttr'"
-              label="属性更改"
-              size="small">
-              <el-button @click="onChangeAttrClick(event_list_item, event_list_index)"
-                >点击配置</el-button
-              >
-            </el-form-item>
-            <el-form-item
-              v-else-if="event_list_item.action == 'customCode'"
-              label="代码编写"
-              size="small">
-              <el-button @click="onCustomCodeClick(event_list_item, event_list_index)"
-                >点击编写</el-button
-              >
-            </el-form-item>
-            <el-form-item
-              v-else-if="event_list_item.action == 'openWebtopo'"
-              label="目标组态"
-              size="small">
-              <div class="webtopo-project-field">
-                <el-button @click="onWebtopoProjectClick(event_list_index)">选择组态</el-button>
-                <el-text
-                  truncated
-                  class="webtopo-project-name"
-                  :title="event_list_item.webtopo_project?.projectName">
-                  {{ event_list_item.webtopo_project?.projectName || '未选择' }}
-                </el-text>
+                <el-radio-button v-for="item in event_action" :key="item.value" :value="item.value">
+                  {{ item.label }}
+                </el-radio-button>
+              </el-radio-group>
+
+              <div class="action-config">
+                <template v-if="event_list_item.action === 'changeAttr'">
+                  <div>
+                    <strong>属性更改</strong>
+                    <span>已配置 {{ event_list_item.change_attr.length }} 项</span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onChangeAttrClick(event_list_item, event_list_index)">
+                    {{ event_list_item.change_attr.length ? '编辑配置' : '开始配置' }}
+                  </el-button>
+                </template>
+                <template v-else-if="event_list_item.action === 'customCode'">
+                  <div>
+                    <strong>自定义代码</strong>
+                    <span>{{ event_list_item.custom_code ? '已编写代码' : '尚未编写代码' }}</span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onCustomCodeClick(event_list_item, event_list_index)">
+                    {{ event_list_item.custom_code ? '编辑代码' : '编写代码' }}
+                  </el-button>
+                </template>
+                <template v-else>
+                  <div class="action-config__project">
+                    <strong>目标组态</strong>
+                    <span :title="event_list_item.webtopo_project?.projectName">
+                      {{ event_list_item.webtopo_project?.projectName || '尚未选择组态' }}
+                    </span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onWebtopoProjectClick(event_list_index)">
+                    选择组态
+                  </el-button>
+                </template>
               </div>
-            </el-form-item>
-            <el-form-item label="触发规则" size="small">
-              <el-text>(不填直接触发)</el-text>
-            </el-form-item>
-            <div>
-              <el-text>当</el-text>
-              <el-select
-                placeholder="选择图形"
-                size="small"
-                v-model="event_list[event_list_index].trigger_rule.trigger_id"
-                @change="onTriggerIdChange(event_list_index)">
-                <el-option
-                  v-for="item in all_component_options"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <el-text>图形</el-text>
-              <el-select
-                placeholder="选择属性"
-                size="small"
-                v-model="event_list[event_list_index].trigger_rule.trigger_attr"
-                @change="onTriggerAttrChange(event_list_index)">
-                <el-option
-                  v-for="item in getAttrById(event_list[event_list_index].trigger_rule.trigger_id)"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <el-text>属性</el-text>
-              <el-select
-                placeholder="运算符"
+            </section>
+
+            <section class="event-step event-condition-step">
+              <div class="event-step__heading">
+                <span>3</span>
+                <div>
+                  <strong>触发条件</strong>
+                  <p>可选,不设置条件则直接触发</p>
+                </div>
+                <el-tag
+                  class="event-step__status"
+                  size="small"
+                  :type="getTriggerConditionStatus(event_list_item).type"
+                  effect="light">
+                  {{ getTriggerConditionStatus(event_list_item).label }}
+                </el-tag>
+              </div>
+
+              <el-button
+                v-if="!isTriggerEditorVisible(event_list_item)"
+                class="add-condition-button"
+                plain
                 size="small"
-                v-model="event_list[event_list_index].trigger_rule.operator">
-                <el-option
-                  v-for="item in getOperatorList(
-                    getAttrTypeByValue(
-                      getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                      event_list[event_list_index].trigger_rule.trigger_attr
-                    )
-                  )"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <input-target-value
-                v-model="event_list[event_list_index].trigger_rule.value"
-                :form-type="
-                  getAttrTypeByValue(
-                    getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                    event_list[event_list_index].trigger_rule.trigger_attr
-                  )
-                "
-                :options="
-                  getAttrOptionsByValue(
-                    getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                    event_list[event_list_index].trigger_rule.trigger_attr
-                  )
-                "
-                :disabled="event_list[event_list_index].trigger_rule.trigger_attr == undefined"
-                size="small" />
-              <el-text>时</el-text>
-              <div>
-                <el-text>触发该事件</el-text>
+                @click="onAddTriggerCondition(event_list_item.id)">
+                <el-icon><Plus /></el-icon>
+                添加触发条件
+              </el-button>
+
+              <div v-else class="condition-builder">
+                <el-form-item label="当这个图形" size="small">
+                  <el-select
+                    v-model="event_list[event_list_index].trigger_rule.trigger_id"
+                    filterable
+                    placeholder="选择图形"
+                    @change="onTriggerIdChange(event_list_index)">
+                    <el-option
+                      v-for="item in all_component_options"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value" />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="指定属性" size="small">
+                  <el-select
+                    v-model="event_list[event_list_index].trigger_rule.trigger_attr"
+                    filterable
+                    placeholder="选择属性"
+                    :disabled="!event_list[event_list_index].trigger_rule.trigger_id"
+                    @change="onTriggerAttrChange(event_list_index)">
+                    <el-option
+                      v-for="item in getAttrById(
+                        event_list[event_list_index].trigger_rule.trigger_id
+                      )"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value" />
+                  </el-select>
+                </el-form-item>
+                <div class="condition-builder__comparison">
+                  <el-form-item label="满足关系" size="small">
+                    <el-select
+                      v-model="event_list[event_list_index].trigger_rule.operator"
+                      placeholder="运算符"
+                      :disabled="!event_list[event_list_index].trigger_rule.trigger_attr">
+                      <el-option
+                        v-for="item in getOperatorList(
+                          getAttrTypeByValue(
+                            getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                            event_list[event_list_index].trigger_rule.trigger_attr
+                          )
+                        )"
+                        :key="item.value"
+                        :label="item.label"
+                        :value="item.value" />
+                    </el-select>
+                  </el-form-item>
+                  <el-form-item label="目标值" size="small">
+                    <input-target-value
+                      v-model="event_list[event_list_index].trigger_rule.value"
+                      :form-type="
+                        getAttrTypeByValue(
+                          getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                          event_list[event_list_index].trigger_rule.trigger_attr
+                        )
+                      "
+                      :options="
+                        getAttrOptionsByValue(
+                          getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                          event_list[event_list_index].trigger_rule.trigger_attr
+                        )
+                      "
+                      :disabled="!event_list[event_list_index].trigger_rule.trigger_attr"
+                      size="small" />
+                  </el-form-item>
+                </div>
+                <div class="condition-builder__footer">
+                  <span>以上条件满足时执行该事件</span>
+                  <el-button
+                    type="danger"
+                    text
+                    size="small"
+                    @click="onRemoveTriggerCondition(event_list_item, event_list_index)">
+                    移除条件
+                  </el-button>
+                </div>
               </div>
-            </div>
+            </section>
           </div>
         </el-collapse-item>
       </el-collapse>
@@ -224,19 +334,23 @@
 <script setup lang="ts">
 import {
   ElButton,
-  ElForm,
-  ElFormItem,
-  ElSelect,
-  ElOption,
-  ElText,
   ElCollapse,
   ElCollapseItem,
+  ElDialog,
+  ElDrawer,
+  ElEmpty,
+  ElForm,
+  ElFormItem,
   ElIcon,
+  ElOption,
   ElPopconfirm,
-  ElDrawer,
-  ElDialog
+  ElRadioButton,
+  ElRadioGroup,
+  ElSelect,
+  ElTag
 } from 'element-plus'
 import { computed, ref, watch } from 'vue'
+import { CopyDocument, Delete, Plus } from '@element-plus/icons-vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import { randomString } from '@/components/mt-edit/utils'
 import type {
@@ -314,8 +428,9 @@ const all_component_options = computed(() => {
     }
   })
 })
-const activeNames = ref([])
+const activeNames = ref('')
 const event_list = computed(() => selectItemEventSettingProps.itemEvents)
+const trigger_editor_event_ids = ref<string[]>([])
 const select_event_index = ref(0)
 const drawer_visiable = ref(false)
 const drawer_title = ref('属性更改配置')
@@ -325,8 +440,9 @@ const dialog_title = ref('自定义代码编写')
 const dialog_code = ref('')
 const webtopo_project_dialog_visible = ref(false)
 const onAddEvent = () => {
+  const id = randomString()
   event_list.value.push({
-    id: randomString(),
+    id,
     type: 'click',
     action: 'changeAttr',
     change_attr: [],
@@ -339,9 +455,30 @@ const onAddEvent = () => {
       value: null
     }
   })
+  activeNames.value = id
 }
 const onDelEvent = (event_list_index: number) => {
+  const eventId = event_list.value[event_list_index]?.id
   event_list.value.splice(event_list_index, 1)
+  trigger_editor_event_ids.value = trigger_editor_event_ids.value.filter((id) => id !== eventId)
+  if (activeNames.value === eventId) {
+    activeNames.value = ''
+  }
+}
+const onCopyEvent = (item: IDoneJsonEventList, index: number) => {
+  const id = randomString()
+  const copiedEvent: IDoneJsonEventList = {
+    ...item,
+    id,
+    change_attr: item.change_attr.map((config) => ({ ...config, id: randomString() })),
+    trigger_rule: { ...item.trigger_rule },
+    webtopo_project: item.webtopo_project ? { ...item.webtopo_project } : undefined
+  }
+  event_list.value.splice(index + 1, 0, copiedEvent)
+  activeNames.value = id
+  if (isTriggerEditorVisible(item)) {
+    trigger_editor_event_ids.value.push(id)
+  }
 }
 const onChangeAttrClick = (item: IDoneJsonEventList, index: number) => {
   drawer_visiable.value = true
@@ -385,6 +522,52 @@ const onWebtopoProjectClick = (index: number) => {
 const onWebtopoProjectSelected = (project: IDoneJsonEventWebtopoProject) => {
   event_list.value[select_event_index.value].webtopo_project = project
 }
+const getEventSummary = (item: IDoneJsonEventList) => {
+  const typeLabel = event_type.find((type) => type.value === item.type)?.label || '未选择触发方式'
+  const actionLabel =
+    event_action.find((action) => action.value === item.action)?.label || '未选择执行动作'
+  return `${typeLabel} · ${actionLabel}`
+}
+const hasTriggerCondition = (item: IDoneJsonEventList) => {
+  const { trigger_id, trigger_attr, operator, value } = item.trigger_rule
+  return Boolean(
+    trigger_id && trigger_attr && operator && value !== null && value !== undefined && value !== ''
+  )
+}
+const hasTriggerRuleInput = (item: IDoneJsonEventList) => {
+  const { trigger_id, trigger_attr, operator, value } = item.trigger_rule
+  return Boolean(
+    trigger_id ||
+      trigger_attr ||
+      operator ||
+      (value !== null && value !== undefined && value !== '')
+  )
+}
+const isTriggerEditorVisible = (item: IDoneJsonEventList) =>
+  trigger_editor_event_ids.value.includes(item.id) || hasTriggerRuleInput(item)
+const getTriggerConditionStatus = (item: IDoneJsonEventList) => {
+  if (hasTriggerCondition(item)) {
+    return { label: '已设置', type: 'primary' as const }
+  }
+  if (isTriggerEditorVisible(item)) {
+    return { label: '待完善', type: 'warning' as const }
+  }
+  return { label: '直接触发', type: 'info' as const }
+}
+const onAddTriggerCondition = (eventId: string) => {
+  if (!trigger_editor_event_ids.value.includes(eventId)) {
+    trigger_editor_event_ids.value.push(eventId)
+  }
+}
+const onRemoveTriggerCondition = (item: IDoneJsonEventList, eventIndex: number) => {
+  event_list.value[eventIndex].trigger_rule = {
+    trigger_id: undefined,
+    trigger_attr: undefined,
+    operator: undefined,
+    value: null
+  }
+  trigger_editor_event_ids.value = trigger_editor_event_ids.value.filter((id) => id !== item.id)
+}
 /**
  * 根据id获取属性
  * @param id
@@ -517,16 +700,384 @@ watch(event_list.value, (val) => {
 })
 </script>
 <style scoped>
-.webtopo-project-field {
+.event-editor {
+  min-width: 0;
+}
+
+.event-editor__header {
+  display: flex;
+  margin-bottom: 10px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.event-editor__header > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.event-editor__header strong {
+  font-size: 13px;
+  line-height: 20px;
+  color: #344054;
+}
+
+.event-editor__header span {
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+}
+
+.event-editor__header :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.event-editor__empty {
+  padding: 44px 0 28px;
+  background: #f8fafc;
+  border: 1px dashed #d9e0e9;
+  border-radius: 10px;
+}
+
+.event-editor__empty :deep(.el-empty__description) {
+  margin-top: 8px;
+}
+
+.event-editor__form {
+  min-width: 0;
+}
+
+.event-list {
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item) {
+  margin-bottom: 10px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #dfe5ed;
+  border-radius: 10px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.event-list :deep(.el-collapse-item.is-active) {
+  border-color: var(--el-color-primary-light-6);
+  box-shadow: 0 5px 16px rgb(31 45 61 / 7%);
+}
+
+.event-list :deep(.el-collapse-item__header) {
+  height: 56px;
+  padding: 0 8px 0 10px;
+  background: transparent;
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item__arrow) {
+  margin-left: 3px;
+  color: #7f8a9b;
+}
+
+.event-list :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item__content) {
+  padding: 0;
+}
+
+.event-card__title {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+  gap: 8px;
+}
+
+.event-card__number {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 1;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.event-card__heading {
+  display: flex;
+  min-width: 0;
+  margin-right: auto;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.event-card__heading strong {
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 18px;
+  color: #344054;
+}
+
+.event-card__heading span {
+  overflow: hidden;
+  font-size: 10px;
+  font-weight: 400;
+  line-height: 15px;
+  color: #8b96a7;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.event-card__icon-button {
+  width: 26px;
+  height: 26px;
+  margin: 0;
+  color: #7c8798;
+  flex: none;
+}
+
+.event-card__icon-button:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.event-card__delete:hover {
+  color: var(--el-color-danger);
+  background: var(--el-color-danger-light-9);
+}
+
+.event-card__body {
+  padding: 2px 11px 12px;
+  border-top: 1px solid #edf0f4;
+}
+
+.event-step {
+  padding: 13px 0;
+  border-bottom: 1px solid #edf0f4;
+}
+
+.event-step:last-child {
+  padding-bottom: 2px;
+  border-bottom: 0;
+}
+
+.event-step__heading {
+  display: flex;
+  min-width: 0;
+  margin-bottom: 10px;
+  align-items: center;
+  gap: 8px;
+}
+
+.event-step__heading > span:first-child {
+  display: inline-flex;
+  width: 20px;
+  height: 20px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 1;
+  color: #617087;
+  background: #eef2f7;
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.event-step__heading > div {
   display: flex;
+  min-width: 0;
+  flex-direction: column;
+}
+
+.event-step__heading strong {
+  font-size: 12px;
+  line-height: 18px;
+  color: #344054;
+}
+
+.event-step__heading p {
+  margin: 0;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.event-step__status {
+  margin-left: auto;
+  flex: none;
+}
+
+.event-type-options,
+.event-action-options {
+  display: grid;
+  width: 100%;
+  gap: 6px;
+}
+
+.event-type-options {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.event-action-options {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.event-type-options :deep(.el-radio-button),
+.event-action-options :deep(.el-radio-button) {
+  width: 100%;
+}
+
+.event-type-options :deep(.el-radio-button__inner),
+.event-action-options :deep(.el-radio-button__inner) {
   width: 100%;
+  padding: 0 6px;
+  font-size: 11px;
+  line-height: 28px;
+  border: 1px solid #dce3ec !important;
+  border-radius: 7px !important;
+  box-shadow: none !important;
+}
+
+.event-type-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner),
+.event-action-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-5) !important;
+}
+
+.action-config {
+  display: flex;
+  min-width: 0;
+  padding: 9px 10px;
+  margin-top: 9px;
+  background: #f7f9fc;
+  border: 1px solid #e7ebf1;
+  border-radius: 8px;
+  align-items: center;
+  gap: 8px;
+}
+
+.action-config > div {
+  display: flex;
   min-width: 0;
+  margin-right: auto;
   flex-direction: column;
-  align-items: flex-start;
+  gap: 1px;
+}
+
+.action-config strong {
+  font-size: 11px;
+  line-height: 17px;
+  color: #475467;
+}
+
+.action-config span {
+  overflow: hidden;
+  font-size: 10px;
+  line-height: 15px;
+  color: #8c97a8;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.action-config__project {
+  max-width: 122px;
+}
+
+.add-condition-button {
+  width: 100%;
+  height: 32px;
+  color: #667085;
+  border-style: dashed;
+}
+
+.add-condition-button :deep(span) {
+  gap: 5px;
+}
+
+.condition-builder {
+  padding: 10px;
+  background: #f7f9fc;
+  border: 1px solid #e4e9f1;
+  border-radius: 8px;
+}
+
+.condition-builder :deep(.el-form-item) {
+  margin-bottom: 9px;
+}
+
+.condition-builder :deep(.el-form-item__label) {
+  height: auto;
+  padding: 0 0 4px;
+  font-size: 10px;
+  line-height: 16px;
+  color: #667085;
+}
+
+.condition-builder :deep(.el-select),
+.condition-builder :deep(.el-input),
+.condition-builder :deep(.el-input-number) {
+  width: 100%;
+}
+
+.condition-builder__comparison {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 8px;
+}
+
+.condition-builder__footer {
+  display: flex;
+  min-height: 24px;
+  padding-top: 2px;
+  align-items: center;
+  justify-content: space-between;
   gap: 8px;
 }
 
-.webtopo-project-name {
-  max-width: 100%;
+.condition-builder__footer > span {
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.condition-builder__footer :deep(.el-button) {
+  padding: 3px 0;
+  margin: 0;
+}
+
+.dark .event-editor__header strong,
+.dark .event-card__heading strong,
+.dark .event-step__heading strong {
+  color: #dce4ee;
+}
+
+.dark .event-editor__empty,
+.dark .event-list :deep(.el-collapse-item),
+.dark .action-config,
+.dark .condition-builder {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .event-card__body,
+.dark .event-step {
+  border-color: #303949;
+}
+
+.dark .action-config strong {
+  color: #cbd5e1;
 }
 </style>

+ 45 - 9
src/components/mt-edit/index.vue

@@ -2,13 +2,14 @@
   <div id="mt-edit" class="relative flex-auto w-1/1 h-1/1 dark">
     <el-container class="h-1/1">
       <el-header
-        height="45px"
+        height="54px"
         class="dark:bg-myDarkBgColor cb-border p-0 select-none flex"
         @mousedown="mainPanelRef?.stopListenerKeyDown()">
         <header-panel
           v-model:leftAside="aside_state.left_show"
           v-model:rightAside="aside_state.right_show"
           v-model:lock-state="globalStore.lock"
+          :title="mtEidtProps.title"
           :selected-items-id="globalStore.selected_items_id"
           :group-enabled="header_group_enabled"
           :un-group-enabled="header_un_group_enabled"
@@ -24,7 +25,6 @@
           @on-delete-click="onDeleteClick"
           @on-export-click="onExportClick"
           @on-tree-click="done_json_tree_visiable = true"
-          @on-help-click="onHelpClick"
           @align-selected="onAlignSelected"
           @on-redo-click="onRedoClick"
           @on-undo-click="onUndoClick"
@@ -35,9 +35,9 @@
           @on-thumbnail-click="onThumbnailClick"
           @on-draw-line-click="onDrawLineClick" />
       </el-header>
-      <el-container class="h-[calc(100%-45px-40px)]">
+      <el-container class="h-[calc(100%-54px)]">
         <el-aside
-          :width="aside_state.left_show ? '200px' : '0px'"
+          :width="aside_state.left_show ? '278px' : '0px'"
           class="dark:bg-myDarkBgColor cr-border mt-edit-aside h-1/1 select-none"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <left-aside :leftAsideConfig="leftAsideStore.config" />
@@ -53,7 +53,7 @@
             :line-append-enable="line_append_enable" />
         </el-main>
         <el-aside
-          :width="aside_state.right_show ? '200px' : '0px'"
+          :width="aside_state.right_show ? '300px' : '0px'"
           class="dark:bg-myDarkBgColor cl-border mt-edit-aside select-none"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <right-aside>
@@ -85,12 +85,18 @@
         :canvas-cfg="globalStore.canvasCfg"
         :grid-cfg="globalStore.gridCfg" />
     </el-dialog>
-    <el-drawer v-model="done_json_tree_visiable" title="图形结构树" direction="ltr" size="30%">
+    <el-drawer
+      v-model="done_json_tree_visiable"
+      title="组件树"
+      direction="ltr"
+      size="480px"
+      class="mt-layer-drawer">
       <done-tree
         :done-json="globalStore.done_json"
         :selected-items-id="globalStore.selected_items_id"
         @update-selected-items-id="onTreeUpdateSelectedItemsId"
         @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide"
+        @update-all-hide="onDoneTreeUpdateAllHide"
         @move-layer="onDoneTreeMoveLayer" />
     </el-drawer>
   </div>
@@ -121,10 +127,12 @@ import { objectDeepClone } from './utils'
 import { genExportJson, useExportJsonToDoneJson } from './composables'
 import type { IExportJson } from './components/types'
 type MtEditProps = {
+  title?: string
   useThumbnail?: boolean
   saveLoading?: boolean
 }
 const mtEidtProps = withDefaults(defineProps<MtEditProps>(), {
+  title: '未命名项目',
   useThumbnail: false,
   saveLoading: false
 })
@@ -181,7 +189,22 @@ const onDoneTreeUpdateSelectedIdHide = (id: string) => {
   const item = globalStore.done_json.find((f) => f.id === id)
   if (item) {
     item.hide = !item.hide
+    globalStore.setGlobalStoreDoneJson([...globalStore.done_json])
+    cacheStore.addHistory(globalStore.done_json)
+  }
+}
+const onDoneTreeUpdateAllHide = (hide: boolean) => {
+  const hasChange = globalStore.done_json.some((item) => item.hide !== hide)
+  if (!hasChange) {
+    return
   }
+  globalStore.setGlobalStoreDoneJson(
+    globalStore.done_json.map((item) => ({
+      ...item,
+      hide
+    }))
+  )
+  cacheStore.addHistory(globalStore.done_json)
 }
 const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' | 'down' }) => {
   const index = globalStore.done_json.findIndex((f) => f.id === id)
@@ -216,9 +239,6 @@ const onAlignSelected = (
 ) => {
   mainPanelRef.value?.onAlignSelected(type)
 }
-const onHelpClick = () => {
-  window.open('http://mt.yaolm.top')
-}
 const onRedoClick = () => {
   mainPanelRef.value?.onRedo()
 }
@@ -289,3 +309,19 @@ defineExpose({
   transition: width 0.3s;
 }
 </style>
+
+<style>
+.mt-layer-drawer .el-drawer__header {
+  padding: 17px 20px;
+  margin-bottom: 0;
+  font-size: 16px;
+  font-weight: 700;
+  color: #172033;
+  border-bottom: 1px solid #e3e8ef;
+}
+
+.mt-layer-drawer .el-drawer__body {
+  padding: 0;
+  overflow: hidden;
+}
+</style>

+ 752 - 90
src/views/maotu/components/DeviceBindPanel.vue

@@ -1,91 +1,258 @@
 <template>
-  <div class="w-1/1">
-    <el-button class="w-1/1" @click="openDrawer">点击配置</el-button>
-
-    <el-drawer v-model="drawerVisible" title="配置设备绑定" direction="ltr" size="520px">
-      <el-empty v-if="!devices.length" description="当前组态项目未绑定设备" />
-
-      <template v-else>
-        <el-form ref="formRef" :model="formModel" label-position="top" size="small">
-          <div
-            v-for="(bindItem, index) in formModel.deviceBinds"
-            :key="bindItem.id"
-            class="mb-12px">
-            <el-form-item
-              label="选择设备"
-              :prop="`deviceBinds.${index}.deviceId`"
-              :rules="rules.deviceId">
-              <el-select
-                v-model="bindItem.deviceId"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择设备"
-                :disabled="!bindItem.editing"
-                @change="onDeviceChange(bindItem)">
-                <el-option
-                  v-for="device in devices"
-                  :key="device.id"
-                  :label="formatDeviceLabel(device)"
-                  :value="device.id" />
-              </el-select>
-            </el-form-item>
-
-            <el-form-item
-              label="设备属性"
-              :prop="`deviceBinds.${index}.deviceProp`"
-              :rules="rules.deviceProp">
-              <el-select
-                v-model="bindItem.deviceProp"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择设备属性"
-                :disabled="!bindItem.editing || !bindItem.deviceId">
-                <el-option
-                  v-for="deviceProp in getDeviceProps(bindItem.deviceId)"
-                  :key="deviceProp.value"
-                  :label="deviceProp.label"
-                  :value="deviceProp.value" />
-              </el-select>
-            </el-form-item>
-
-            <el-form-item
-              label="图形属性"
-              :prop="`deviceBinds.${index}.nodeProp`"
-              :rules="rules.nodeProp">
-              <el-select
-                v-model="bindItem.nodeProp"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择图形属性"
-                :disabled="!bindItem.editing">
-                <el-option
-                  v-for="nodeProp in nodePropOptions"
-                  :key="nodeProp.value"
-                  :label="nodeProp.label"
-                  :value="nodeProp.value" />
-              </el-select>
-            </el-form-item>
-
-            <el-button
-              v-if="bindItem.editing"
-              type="success"
-              size="small"
-              class="w-1/1"
-              @click="saveBind(index)">
-              绑定
-            </el-button>
-            <el-button v-else type="danger" size="small" class="w-1/1" @click="removeBind(index)">
-              解绑
-            </el-button>
+  <div class="device-bind-panel">
+    <div class="device-bind-overview" :class="{ 'is-empty': !bindCount }">
+      <div class="device-bind-overview__icon">
+        <el-icon><Connection /></el-icon>
+      </div>
+      <div class="device-bind-overview__content">
+        <div class="device-bind-overview__title">
+          <strong>设备数据绑定</strong>
+          <el-tag size="small" :type="bindCount ? 'primary' : 'info'" effect="light">
+            {{ bindCount ? `${bindCount} 项` : '未绑定' }}
+          </el-tag>
+        </div>
+        <p>
+          {{
+            bindCount
+              ? `已将 ${bindCount} 个设备数据点映射到当前组件`
+              : '将设备实时数据映射到组件属性'
+          }}
+        </p>
+        <div class="device-bind-overview__meta">
+          <span>{{ devices.length }} 台可用设备</span>
+          <span>{{ nodePropOptions.length }} 个可绑定属性</span>
+        </div>
+      </div>
+      <el-button type="primary" plain size="small" @click="openDrawer">
+        {{ bindCount ? '管理绑定' : '开始绑定' }}
+      </el-button>
+    </div>
+
+    <el-drawer
+      v-model="drawerVisible"
+      title="配置设备绑定"
+      direction="rtl"
+      size="600px"
+      class="device-bind-drawer"
+      destroy-on-close>
+      <template #header>
+        <div class="device-bind-drawer__header">
+          <span class="device-bind-drawer__header-icon">
+            <el-icon><Connection /></el-icon>
+          </span>
+          <div>
+            <h2>设备绑定</h2>
+            <p>将设备数据点映射到“{{ item.title || '当前组件' }}”的可绑定属性</p>
+          </div>
+        </div>
+      </template>
 
-            <el-divider />
+      <div class="device-bind-workspace">
+        <div class="device-bind-toolbar">
+          <div>
+            <strong>绑定关系</strong>
+            <span>{{ savedBindCount }} 项已生效</span>
           </div>
-        </el-form>
+          <el-button
+            type="primary"
+            size="small"
+            :disabled="!devices.length || !nodePropOptions.length || hasEditingBind"
+            @click="addBind">
+            <el-icon><Plus /></el-icon>
+            新增绑定
+          </el-button>
+        </div>
+
+        <el-empty
+          v-if="!devices.length"
+          class="device-bind-empty"
+          :image-size="92"
+          description="当前组态项目还没有可用设备">
+          <p>请先返回项目管理,为当前组态关联设备</p>
+        </el-empty>
+
+        <el-empty
+          v-else-if="!nodePropOptions.length"
+          class="device-bind-empty"
+          :image-size="92"
+          description="当前组件没有可绑定属性">
+          <p>该组件的属性暂不支持设备数据绑定</p>
+        </el-empty>
+
+        <template v-else>
+          <el-empty
+            v-if="!formModel.deviceBinds.length"
+            class="device-bind-empty"
+            :image-size="82"
+            description="暂未建立绑定关系">
+            <el-button type="primary" plain size="small" @click="addBind">
+              创建第一条绑定
+            </el-button>
+          </el-empty>
+
+          <el-form
+            v-else
+            ref="formRef"
+            :model="formModel"
+            label-position="top"
+            size="small"
+            class="device-bind-list">
+            <article
+              v-for="(bindItem, index) in formModel.deviceBinds"
+              :key="bindItem.id"
+              class="device-bind-card"
+              :class="{ 'is-editing': bindItem.editing }">
+              <template v-if="bindItem.editing">
+                <header class="device-bind-card__editor-header">
+                  <div>
+                    <span>{{ bindItem.isNew ? '新增绑定' : '编辑绑定' }}</span>
+                    <strong>配置数据来源与目标属性</strong>
+                  </div>
+                  <el-tag size="small" type="warning" effect="light">编辑中</el-tag>
+                </header>
 
-        <el-button type="primary" size="small" class="w-1/1" @click="addBind">新增</el-button>
+                <div class="device-bind-card__editor-body">
+                  <section class="bind-editor-step">
+                    <div class="bind-editor-step__heading">
+                      <span>1</span>
+                      <div>
+                        <strong>选择数据来源</strong>
+                        <p>先选择设备,再选择设备数据点</p>
+                      </div>
+                    </div>
+                    <el-form-item
+                      label="设备"
+                      :prop="`deviceBinds.${index}.deviceId`"
+                      :rules="rules.deviceId">
+                      <el-select
+                        v-model="bindItem.deviceId"
+                        filterable
+                        clearable
+                        placeholder="搜索或选择设备"
+                        @change="onDeviceChange(bindItem)">
+                        <el-option
+                          v-for="device in devices"
+                          :key="device.id"
+                          :label="formatDeviceLabel(device)"
+                          :value="device.id" />
+                      </el-select>
+                    </el-form-item>
+                    <el-form-item
+                      label="设备数据点"
+                      :prop="`deviceBinds.${index}.deviceProp`"
+                      :rules="rules.deviceProp">
+                      <el-select
+                        v-model="bindItem.deviceProp"
+                        filterable
+                        clearable
+                        placeholder="选择需要读取的数据点"
+                        :disabled="!bindItem.deviceId">
+                        <el-option
+                          v-for="deviceProp in getDeviceProps(bindItem.deviceId)"
+                          :key="deviceProp.value"
+                          :label="deviceProp.label"
+                          :value="deviceProp.value" />
+                      </el-select>
+                    </el-form-item>
+                  </section>
+
+                  <section class="bind-editor-step">
+                    <div class="bind-editor-step__heading">
+                      <span>2</span>
+                      <div>
+                        <strong>选择映射目标</strong>
+                        <p>设备数据会实时写入所选组件属性</p>
+                      </div>
+                    </div>
+                    <el-form-item
+                      label="组件属性"
+                      :prop="`deviceBinds.${index}.nodeProp`"
+                      :rules="rules.nodeProp">
+                      <el-select
+                        v-model="bindItem.nodeProp"
+                        filterable
+                        clearable
+                        placeholder="选择要驱动的组件属性">
+                        <el-option
+                          v-for="nodeProp in nodePropOptions"
+                          :key="nodeProp.value"
+                          :label="nodeProp.label"
+                          :value="nodeProp.value" />
+                      </el-select>
+                    </el-form-item>
+                  </section>
+                </div>
+
+                <footer class="device-bind-card__editor-footer">
+                  <el-button size="small" @click="cancelEdit(index)">取消</el-button>
+                  <el-button type="primary" size="small" @click="saveBind(index)">
+                    <el-icon><Check /></el-icon>
+                    保存绑定
+                  </el-button>
+                </footer>
+              </template>
+
+              <template v-else>
+                <header class="device-bind-card__summary-header">
+                  <span class="device-bind-card__device-icon">
+                    <el-icon><Monitor /></el-icon>
+                  </span>
+                  <div>
+                    <strong>{{ getDeviceLabel(bindItem.deviceId) }}</strong>
+                    <span>{{ getDeviceCode(bindItem.deviceId) }}</span>
+                  </div>
+                  <el-button
+                    text
+                    circle
+                    size="small"
+                    aria-label="编辑绑定"
+                    :disabled="hasEditingBind"
+                    @click="editBind(index)">
+                    <el-icon><EditPen /></el-icon>
+                  </el-button>
+                  <el-popconfirm
+                    width="210"
+                    title="确认解除这条设备绑定吗?"
+                    confirm-button-text="解绑"
+                    cancel-button-text="取消"
+                    @confirm="removeBind(index)">
+                    <template #reference>
+                      <el-button
+                        text
+                        circle
+                        size="small"
+                        type="danger"
+                        aria-label="解除绑定"
+                        :disabled="hasEditingBind"
+                        @click.stop>
+                        <el-icon><Delete /></el-icon>
+                      </el-button>
+                    </template>
+                  </el-popconfirm>
+                </header>
+
+                <div class="device-bind-card__mapping">
+                  <div>
+                    <span>设备数据点</span>
+                    <strong>{{ getDevicePropLabel(bindItem) }}</strong>
+                  </div>
+                  <span class="device-bind-card__mapping-arrow">→</span>
+                  <div>
+                    <span>组件属性</span>
+                    <strong>{{ getNodePropLabel(bindItem.nodeProp) }}</strong>
+                  </div>
+                </div>
+              </template>
+            </article>
+          </el-form>
+        </template>
+      </div>
+
+      <template #footer>
+        <div class="device-bind-drawer__footer">
+          <span>保存单条绑定后立即生效</span>
+          <el-button type="primary" @click="drawerVisible = false">完成</el-button>
+        </div>
       </template>
     </el-drawer>
   </div>
@@ -94,11 +261,13 @@
 <script setup lang="ts">
 import type { WebtopoProjectLinkedDeviceVO } from '@/api/pms/maotu'
 import type { IDoneJson, IDoneJsonDeviceBind } from '@/components/mt-edit/store/types'
+import { Check, Connection, Delete, EditPen, Monitor, Plus } from '@element-plus/icons-vue'
 import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
-import { computed, reactive, ref } from 'vue'
+import { computed, nextTick, reactive, ref } from 'vue'
 
 type DeviceBindRow = IDoneJsonDeviceBind & {
   editing?: boolean
+  isNew?: boolean
 }
 
 type Props = {
@@ -115,6 +284,7 @@ const formRef = ref<FormInstance>()
 const formModel = reactive({
   deviceBinds: [] as DeviceBindRow[]
 })
+const editBackup = ref<DeviceBindRow>()
 
 const rules: Record<string, FormItemRule | FormItemRule[]> = {
   deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
@@ -130,6 +300,9 @@ const nodePropOptions = computed(() => {
       value: `props.${key}.val`
     }))
 })
+const bindCount = computed(() => props.item.deviceBinds?.length || 0)
+const savedBindCount = computed(() => bindCount.value)
+const hasEditingBind = computed(() => formModel.deviceBinds.some((item) => item.editing))
 
 function randomId() {
   return `device-bind-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -143,6 +316,33 @@ function formatDeviceLabel(device: WebtopoProjectLinkedDeviceVO) {
   return [device.deviceCode, getChineseName(device.deviceName)].filter(Boolean).join(' - ')
 }
 
+function getDevice(deviceId?: number) {
+  return props.devices.find((item) => item.id === deviceId)
+}
+
+function getDeviceLabel(deviceId?: number) {
+  const device = getDevice(deviceId)
+  return getChineseName(device?.deviceName) || device?.deviceCode || '未知设备'
+}
+
+function getDeviceCode(deviceId?: number) {
+  return getDevice(deviceId)?.deviceCode || '暂无设备编码'
+}
+
+function getDevicePropLabel(bindItem: DeviceBindRow) {
+  return (
+    getDeviceProps(bindItem.deviceId).find((item) => item.value === bindItem.deviceProp)?.label ||
+    bindItem.deviceProp ||
+    '未知数据点'
+  )
+}
+
+function getNodePropLabel(nodeProp?: string) {
+  return (
+    nodePropOptions.value.find((item) => item.value === nodeProp)?.label || nodeProp || '未知属性'
+  )
+}
+
 function updateItemDeviceBinds(item: IDoneJson, deviceBinds: IDoneJsonDeviceBind[]) {
   item.deviceBinds = deviceBinds
 }
@@ -150,15 +350,17 @@ function updateItemDeviceBinds(item: IDoneJson, deviceBinds: IDoneJsonDeviceBind
 function syncDeviceBinds() {
   updateItemDeviceBinds(
     props.item,
-    formModel.deviceBinds.map(({ editing, ...item }) => item)
+    formModel.deviceBinds.map(({ editing, isNew, ...item }) => item)
   )
 }
 
 async function openDrawer() {
   formModel.deviceBinds = (props.item.deviceBinds || []).map((item) => ({
     ...item,
-    editing: false
+    editing: false,
+    isNew: false
   }))
+  editBackup.value = undefined
   drawerVisible.value = true
 }
 
@@ -173,8 +375,10 @@ function addBind() {
     deviceId: undefined,
     deviceProp: '',
     nodeProp: '',
-    editing: true
+    editing: true,
+    isNew: true
   })
+  editBackup.value = undefined
 }
 
 function onDeviceChange(bindItem: DeviceBindRow) {
@@ -182,15 +386,39 @@ function onDeviceChange(bindItem: DeviceBindRow) {
 }
 
 function getDeviceProps(deviceId?: number) {
-  const device = props.devices.find((item) => item.id === deviceId)
+  const device = getDevice(deviceId)
   return (device?.pointParams || [])
     .filter((item) => item.paramCode)
     .map((item) => ({
-      label: item.paramName || item.paramCode!,
+      label: `${item.paramName || item.paramCode!}${item.paramUnit ? ` (${item.paramUnit})` : ''}`,
       value: item.paramCode!
     }))
 }
 
+function editBind(index: number) {
+  if (hasEditingBind.value) {
+    ElMessage.warning('请先完成当前绑定')
+    return
+  }
+  editBackup.value = { ...formModel.deviceBinds[index] }
+  formModel.deviceBinds[index].editing = true
+}
+
+function cancelEdit(index: number) {
+  const currentItem = formModel.deviceBinds[index]
+  if (currentItem.isNew) {
+    formModel.deviceBinds.splice(index, 1)
+  } else if (editBackup.value?.id === currentItem.id) {
+    formModel.deviceBinds[index] = {
+      ...editBackup.value,
+      editing: false,
+      isNew: false
+    }
+  }
+  editBackup.value = undefined
+  nextTick(() => formRef.value?.clearValidate())
+}
+
 async function saveBind(index: number) {
   await formRef.value?.validateField([
     `deviceBinds.${index}.deviceId`,
@@ -198,7 +426,18 @@ async function saveBind(index: number) {
     `deviceBinds.${index}.nodeProp`
   ])
 
+  const currentItem = formModel.deviceBinds[index]
+  const duplicateNodeProp = formModel.deviceBinds.some(
+    (item, itemIndex) => itemIndex !== index && item.nodeProp === currentItem.nodeProp
+  )
+  if (duplicateNodeProp) {
+    ElMessage.warning('该组件属性已经存在设备绑定,请选择其他属性')
+    return
+  }
+
   formModel.deviceBinds[index].editing = false
+  formModel.deviceBinds[index].isNew = false
+  editBackup.value = undefined
   syncDeviceBinds()
   ElMessage.success('绑定成功')
 }
@@ -209,3 +448,426 @@ function removeBind(index: number) {
   ElMessage.success('解绑成功')
 }
 </script>
+
+<style scoped>
+.device-bind-panel {
+  min-width: 0;
+}
+
+.device-bind-overview {
+  display: flex;
+  min-width: 0;
+  padding: 12px;
+  background: linear-gradient(145deg, #f7fbff 0%, #fff 75%);
+  border: 1px solid var(--el-color-primary-light-7);
+  border-radius: 10px;
+  align-items: center;
+  gap: 10px;
+}
+
+.device-bind-overview.is-empty {
+  background: #f8fafc;
+  border-color: #e0e6ee;
+}
+
+.device-bind-overview__icon {
+  display: inline-flex;
+  width: 34px;
+  height: 34px;
+  font-size: 18px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 9px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-overview__content {
+  min-width: 0;
+  margin-right: auto;
+}
+
+.device-bind-overview__title {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 6px;
+}
+
+.device-bind-overview__title strong {
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 18px;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-overview p {
+  margin: 3px 0;
+  overflow: hidden;
+  font-size: 10px;
+  line-height: 15px;
+  color: #7d899b;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-overview__meta {
+  display: flex;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+  gap: 8px;
+}
+
+.device-bind-overview > :deep(.el-button) {
+  padding: 5px 8px;
+  margin: 0;
+  flex: none;
+}
+
+.device-bind-drawer__header {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 11px;
+}
+
+.device-bind-drawer__header-icon {
+  display: inline-flex;
+  width: 38px;
+  height: 38px;
+  font-size: 20px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-drawer__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.device-bind-drawer__header p {
+  max-width: 430px;
+  margin: 3px 0 0;
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-workspace {
+  min-height: 100%;
+}
+
+.device-bind-toolbar {
+  display: flex;
+  min-height: 42px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #e5eaf1;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.device-bind-toolbar > div {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.device-bind-toolbar strong {
+  font-size: 14px;
+  line-height: 20px;
+  color: #344054;
+}
+
+.device-bind-toolbar span {
+  font-size: 11px;
+  line-height: 16px;
+  color: #8a95a6;
+}
+
+.device-bind-toolbar :deep(.el-button > span),
+.device-bind-card__editor-footer :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.device-bind-empty {
+  padding: 70px 0 40px;
+}
+
+.device-bind-empty p {
+  margin: -5px 0 12px;
+  font-size: 12px;
+  color: #98a2b3;
+}
+
+.device-bind-list {
+  display: flex;
+  padding-top: 12px;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.device-bind-card {
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e0e6ee;
+  border-radius: 11px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.device-bind-card:hover {
+  border-color: var(--el-color-primary-light-6);
+  box-shadow: 0 5px 16px rgb(31 45 61 / 6%);
+}
+
+.device-bind-card.is-editing {
+  border-color: var(--el-color-primary-light-5);
+  box-shadow: 0 6px 18px rgb(64 158 255 / 9%);
+}
+
+.device-bind-card__summary-header,
+.device-bind-card__editor-header {
+  display: flex;
+  min-width: 0;
+  padding: 11px 13px;
+  border-bottom: 1px solid #edf0f4;
+  align-items: center;
+  gap: 9px;
+}
+
+.device-bind-card__device-icon {
+  display: inline-flex;
+  width: 32px;
+  height: 32px;
+  font-size: 17px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-card__summary-header > div,
+.device-bind-card__editor-header > div {
+  display: flex;
+  min-width: 0;
+  margin-right: auto;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.device-bind-card__summary-header strong,
+.device-bind-card__editor-header strong {
+  overflow: hidden;
+  font-size: 13px;
+  line-height: 19px;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__summary-header span,
+.device-bind-card__editor-header span {
+  overflow: hidden;
+  font-size: 10px;
+  line-height: 15px;
+  color: #8b96a7;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__summary-header > :deep(.el-button) {
+  width: 28px;
+  height: 28px;
+  margin: 0;
+  flex: none;
+}
+
+.device-bind-card__mapping {
+  display: grid;
+  padding: 13px;
+  background: #fafbfd;
+  grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr);
+  align-items: center;
+  gap: 8px;
+}
+
+.device-bind-card__mapping > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.device-bind-card__mapping span {
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.device-bind-card__mapping strong {
+  overflow: hidden;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 18px;
+  color: #475467;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__mapping-arrow {
+  display: inline-flex;
+  width: 28px;
+  height: 28px;
+  font-size: 15px !important;
+  color: var(--el-color-primary) !important;
+  background: var(--el-color-primary-light-9);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+}
+
+.device-bind-card__editor-header {
+  background: #fbfcfe;
+}
+
+.device-bind-card__editor-body {
+  display: grid;
+  padding: 13px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.bind-editor-step {
+  min-width: 0;
+  padding: 11px;
+  background: #f7f9fc;
+  border: 1px solid #e6ebf2;
+  border-radius: 9px;
+}
+
+.bind-editor-step__heading {
+  display: flex;
+  margin-bottom: 10px;
+  align-items: center;
+  gap: 8px;
+}
+
+.bind-editor-step__heading > span {
+  display: inline-flex;
+  width: 22px;
+  height: 22px;
+  font-size: 10px;
+  font-weight: 700;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.bind-editor-step__heading > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+}
+
+.bind-editor-step__heading strong {
+  font-size: 12px;
+  line-height: 18px;
+  color: #475467;
+}
+
+.bind-editor-step__heading p {
+  margin: 0;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.bind-editor-step :deep(.el-form-item) {
+  margin-bottom: 10px;
+}
+
+.bind-editor-step :deep(.el-form-item:last-child) {
+  margin-bottom: 0;
+}
+
+.bind-editor-step :deep(.el-form-item__label) {
+  height: auto;
+  padding: 0 0 4px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #667085;
+}
+
+.bind-editor-step :deep(.el-select) {
+  width: 100%;
+}
+
+.device-bind-card__editor-footer {
+  display: flex;
+  padding: 10px 13px;
+  background: #fbfcfe;
+  border-top: 1px solid #edf0f4;
+  justify-content: flex-end;
+}
+
+.device-bind-drawer__footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.device-bind-drawer__footer > span {
+  font-size: 12px;
+  color: #8a95a6;
+}
+
+@media (width <= 700px) {
+  .device-bind-card__editor-body {
+    grid-template-columns: 1fr;
+  }
+}
+</style>
+
+<style>
+.device-bind-drawer {
+  max-width: 100vw;
+}
+
+.device-bind-drawer .el-drawer__header {
+  padding: 17px 20px;
+  margin: 0;
+  border-bottom: 1px solid #e4e9f0;
+}
+
+.device-bind-drawer .el-drawer__body {
+  padding: 14px 18px;
+  background: #f6f8fb;
+}
+
+.device-bind-drawer .el-drawer__footer {
+  padding: 12px 18px;
+  border-top: 1px solid #e4e9f0;
+}
+</style>

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

@@ -8,8 +8,8 @@ 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 html2canvas from 'html2canvas'
 import { ElMessage } from 'element-plus'
+import html2canvas from 'html2canvas'
 import { nextTick, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import DeviceBindPanel from './components/DeviceBindPanel.vue'
@@ -173,6 +173,7 @@ watch(() => route.params.id, resetAndLoadProject)
     class="w-1/1 h-100vh">
     <mt-edit
       ref="MtEditRef"
+      :title="projectDetail?.projectName || '拓扑项目'"
       :use-thumbnail="true"
       :save-loading="saveLoading"
       @on-preview-click="onPreviewClick"