Sfoglia il codice sorgente

feat(eleModal): 新增弹窗最小化功能,支持全局悬浮胶囊管理

- 扩展 ele-modal 组件,增加 minimizable 属性开启最小化能力
- 最小化后弹窗隐藏但保留内容,通过全局悬浮胶囊可还原或关闭
- 新增 minimizeManager 实现跨页面最小化弹窗的注册与注销
- 添加相关样式及示例页面配置调整
yusheng 5 giorni fa
parent
commit
ffd2e55837

+ 215 - 0
src/components/eleModal/index.js

@@ -0,0 +1,215 @@
+/**
+ * ele-modal 扩展(覆盖 ele-admin 的 EleModal 全局注册):
+ * 在原有功能基础上增加“最小化”能力:
+ * - 通过 minimizable 属性开启,开启后标题栏出现最小化按钮;
+ * - 最小化后弹窗隐藏但内容保留(不销毁 DOM / 不清空数据);
+ * - 最小化弹窗统一挂到全局悬浮胶囊(任意页面都可见),点击可还原;
+ * - 可通过 minimize-prefix 属性给弹窗名称加前缀,用于区分同名弹窗。
+ */
+import EleModal from 'ele-admin/es/ele-modal';
+import { registerMinimized, unregisterMinimized } from './minimizeManager';
+import './index.scss';
+
+const original = EleModal && EleModal.default ? EleModal.default : EleModal;
+
+const origData =
+  typeof original.data === 'function' ? original.data : () => ({});
+const origWatch = original.watch || {};
+const origActivated =
+  typeof original.activated === 'function' ? original.activated : null;
+const origBeforeDestroy =
+  typeof original.beforeDestroy === 'function' ? original.beforeDestroy : null;
+
+// 保留原有 visible 监听,同时监听“父组件主动关闭时取消最小化”
+const visibleHandlers = [];
+if (origWatch.visible) {
+  visibleHandlers.push(origWatch.visible);
+}
+visibleHandlers.push(function (visible) {
+  if (!visible && this.isMinimized) {
+    this.isMinimized = false;
+    if (this._minimizeId) {
+      unregisterMinimized(this._minimizeId);
+      this._minimizeId = null;
+    }
+  }
+});
+
+export default {
+  ...original,
+  name: 'EleModal',
+  props: {
+    ...original.props,
+    // 是否支持最小化
+    minimizable: {
+      type: Boolean,
+      default: false
+    },
+    // 弹窗名称前缀,悬浮胶囊显示“前缀 + 标题”,用于区分同名弹窗
+    minimizePrefix: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    return {
+      ...origData.call(this),
+      // 是否已最小化
+      isMinimized: false
+    };
+  },
+  computed: {
+    ...original.computed,
+    // 悬浮胶囊上显示的弹窗名称
+    minimizeTitle() {
+      return (this.minimizePrefix || '') + (this.title || '');
+    },
+    // 最小化按钮位置(避让右侧关闭按钮/全屏按钮)
+    minimizeBtnStyle() {
+      return this.maxable ? { right: '68px' } : { right: '44px' };
+    }
+  },
+  watch: {
+    ...origWatch,
+    visible: visibleHandlers
+  },
+  methods: {
+    ...original.methods,
+    // 最小化:隐藏弹窗但保留内容,并注册到全局悬浮胶囊
+    minimize() {
+      if (!this.minimizable) return;
+      this.isMinimized = true;
+      this.modalVisible = false;
+      this._minimizeId = registerMinimized(this);
+      this.$emit('minimize');
+    },
+    // 从悬浮胶囊还原弹窗
+    restore() {
+      this.isMinimized = false;
+      if (this._minimizeId) {
+        unregisterMinimized(this._minimizeId);
+        this._minimizeId = null;
+      }
+      this.modalVisible = true;
+      this.$emit('restore');
+    },
+    // 从悬浮胶囊彻底关闭(同步父组件 visible)
+    closeMinimized() {
+      this.isMinimized = false;
+      if (this._minimizeId) {
+        unregisterMinimized(this._minimizeId);
+        this._minimizeId = null;
+      }
+      this.modalVisible = false;
+      this.updateVisible(false);
+    }
+  },
+  activated() {
+    // 最小化状态下回到页面时不要自动弹出,保持最小化,由悬浮胶囊控制
+    if (this.isMinimized) {
+      this.isActivated = true;
+    } else if (origActivated) {
+      origActivated.call(this);
+    }
+  },
+  beforeDestroy() {
+    // 页面被销毁时若仍处于最小化,移除对应的悬浮胶囊
+    if (this.isMinimized && this._minimizeId) {
+      unregisterMinimized(this._minimizeId);
+      this._minimizeId = null;
+    }
+    if (origBeforeDestroy) {
+      origBeforeDestroy.call(this);
+    }
+  },
+  render(h) {
+    // proxy 标记:让插槽同时暴露到 $slots(el-dialog 依赖 $slots.footer 渲染底部)
+    const titleFn = () => {
+      const nodes = this.$scopedSlots.title
+        ? this.$scopedSlots.title()
+        : [
+            h('span', { class: 'el-dialog__title' }, [String(this.title || '')])
+          ];
+      if (this.minimizable) {
+        nodes.push(
+          h(
+            'button',
+            {
+              class: 'el-dialog__headerbtn ele-modal-icon-minimize',
+              style: this.minimizeBtnStyle,
+              attrs: { type: 'button' },
+              on: { click: this.minimize }
+            },
+            [
+              this.$scopedSlots.minimizeIcon
+                ? this.$scopedSlots.minimizeIcon()
+                : h('i', { class: 'el-dialog__close el-icon el-icon-minus' })
+            ]
+          )
+        );
+      }
+      if (this.maxable) {
+        nodes.push(
+          h(
+            'button',
+            {
+              class: 'el-dialog__headerbtn ele-modal-icon-fullscreen',
+              attrs: { type: 'button' },
+              on: { click: () => this.toggleFullscreen() }
+            },
+            [
+              this.$scopedSlots.maxIcon
+                ? this.$scopedSlots.maxIcon({ fullscreen: this.isFullscreen })
+                : h('i', { class: this.maxIconClass })
+            ]
+          )
+        );
+      }
+      return nodes;
+    };
+    titleFn.proxy = true;
+    const scopedSlots = { title: titleFn };
+    if (this.renderBody && this.$scopedSlots.footer) {
+      const footerFn = () => this.$scopedSlots.footer();
+      footerFn.proxy = true;
+      scopedSlots.footer = footerFn;
+    }
+
+    return h(
+      'el-dialog',
+      {
+        ref: 'modal',
+        class: this.modalClass,
+        style: this.dialogStyle,
+        attrs: {
+          visible: this.modalVisible,
+          title: this.title,
+          width: this.width,
+          top: this.modalTop,
+          modal: this.modalMask,
+          'modal-append-to-body': this.modalAppendToBody,
+          'append-to-body': this.dialogAppendToBody,
+          'lock-scroll': this.lockScroll,
+          'custom-class': this.customClass,
+          'close-on-click-modal': this.maskClosable,
+          'close-on-press-escape': this.closeOnPressEscape,
+          'show-close': this.showClose,
+          'before-close': this.beforeClose,
+          center: this.center,
+          'destroy-on-close': false
+        },
+        on: {
+          'update:visible': this.updateVisible,
+          open: this.onOpen,
+          opened: this.onOpened,
+          close: this.onClose,
+          closed: this.onClosed
+        },
+        scopedSlots
+      },
+      this.renderBody && this.$scopedSlots.default
+        ? [this.$scopedSlots.default()]
+        : []
+    );
+  }
+};

+ 54 - 0
src/components/eleModal/index.scss

@@ -0,0 +1,54 @@
+/* ele-modal 最小化:全局悬浮胶囊(挂在 body 上,任意页面可见) */
+.ele-modal-minimize-container {
+  position: fixed;
+  right: 20px;
+  bottom: 20px;
+  z-index: 2100;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8px;
+  pointer-events: none;
+
+  .ele-modal-minimize-bar {
+    pointer-events: auto;
+    display: flex;
+    align-items: center;
+    max-width: 320px;
+    padding: 8px 14px;
+    background: #409eff;
+    color: #fff;
+    border-radius: 20px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+    cursor: pointer;
+    transition: background-color 0.2s;
+
+    &:hover {
+      background: #66b1ff;
+    }
+
+    .ele-modal-minimize-bar-title {
+      margin: 0 8px;
+      font-size: 13px;
+      line-height: 1;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .ele-modal-minimize-bar-close {
+      padding: 3px;
+      border-radius: 50%;
+      transition: background-color 0.2s;
+
+      &:hover {
+        background: rgba(255, 255, 255, 0.3);
+      }
+    }
+  }
+}
+
+/* 最小化按钮 */
+.ele-modal-icon-minimize {
+  font-size: 14px;
+}

+ 81 - 0
src/components/eleModal/minimizeManager.js

@@ -0,0 +1,81 @@
+/**
+ * ele-modal 最小化全局管理:
+ * 维护所有被最小化的弹窗列表,并在 body 上挂载一个全局悬浮胶囊容器。
+ * 胶囊在任意页面都可见,点击可还原对应的弹窗(弹窗实例由 keep-alive 保活)。
+ */
+import Vue from 'vue';
+
+const state = Vue.observable({ list: [] });
+let seed = 0;
+let containerVm = null;
+
+// 全局悬浮胶囊容器
+const MinimizeContainer = {
+  name: 'EleModalMinimizeContainer',
+  computed: {
+    list() {
+      return state.list;
+    }
+  },
+  render(h) {
+    if (!this.list.length) return h();
+    return h(
+      'div',
+      { class: 'ele-modal-minimize-container' },
+      this.list.map((item) =>
+        h(
+          'div',
+          {
+            class: 'ele-modal-minimize-bar',
+            key: item.id,
+            on: { click: () => item.instance && item.instance.restore() }
+          },
+          [
+            h('i', { class: 'el-icon-minus' }),
+            h('span', { class: 'ele-modal-minimize-bar-title' }, [item.title]),
+            h('i', {
+              class: 'ele-modal-minimize-bar-close el-icon-close',
+              on: {
+                click: (e) => {
+                  e.stopPropagation();
+                  item.instance && item.instance.closeMinimized();
+                }
+              }
+            })
+          ]
+        )
+      )
+    );
+  }
+};
+
+function ensureContainer() {
+  if (containerVm) return containerVm;
+  const holder = document.createElement('div');
+  holder.className = 'ele-modal-minimize-holder';
+  document.body.appendChild(holder);
+  containerVm = new Vue({
+    render: (h) => h(MinimizeContainer)
+  }).$mount(holder);
+  return containerVm;
+}
+
+// 注册一个被最小化的弹窗,返回唯一 id
+export function registerMinimized(modal) {
+  ensureContainer();
+  const item = {
+    id: `ele-min-${++seed}`,
+    title: modal.minimizeTitle,
+    instance: modal
+  };
+  state.list.push(item);
+  return item.id;
+}
+
+// 注销一个被最小化的弹窗
+export function unregisterMinimized(id) {
+  const index = state.list.findIndex((item) => item.id === id);
+  if (index > -1) {
+    state.list.splice(index, 1);
+  }
+}

+ 3 - 0
src/main.js

@@ -7,6 +7,7 @@ import router from './router';
 import permission from './utils/permission';
 import { MAP_KEY, LICENSE_CODE } from '@/config/setting';
 import EleAdmin from 'ele-admin';
+import EleModalExt from '@/components/eleModal';
 import VueClipboard from 'vue-clipboard2';
 import i18n from './i18n';
 import './styles/index.scss';
@@ -47,6 +48,8 @@ Vue.use(EleAdmin, {
   license: LICENSE_CODE,
   i18n: (key, value) => i18n.t(key, value)
 });
+// 覆盖 ele-admin 的 EleModal,增加最小化能力(minimizable 属性开启)
+// Vue.component('EleModal', EleModalExt);
 Vue.use(permission);
 Vue.use(VueClipboard);
 Vue.use(print);

+ 3 - 1
src/views/sample/sampleRecord/index.vue

@@ -9,7 +9,7 @@
         :datasource="datasource"
         @columns-change="handleColumnChange"
         :cache-key="cacheKeyUrl"
-        autoAmendPage
+        :pageSize="20"
       >
         <template v-slot:qualityType="{ row }">
           {{ getDictValue('质检计划类型', row.qualityType) }}
@@ -96,6 +96,8 @@
   import { recordingMethodList } from '@/utils/util.js';
 
   export default {
+    // name: 'sampleRecordList',
+
     mixins: [dictMixins, tableColumnsMixin],
     components: {
       addSample,