فهرست منبع

feat: 增加文本省略组件 (#3180)

* feat: 增加文本省略组件

* refactor: 重构文本省略组件

* feat: 增加远程懒加载下拉树功能
zhang 1 سال پیش
والد
کامیت
87224715c3

+ 4 - 0
src/components/EllipsisText/index.ts

@@ -0,0 +1,4 @@
+import { withInstall } from '@/utils';
+import ellipsisText from './src/EllipsisText.vue';
+
+export const EllipsisText = withInstall(ellipsisText);

+ 133 - 0
src/components/EllipsisText/src/EllipsisText.vue

@@ -0,0 +1,133 @@
+<script setup lang="ts">
+  import { ref, computed, watchEffect, nextTick } from 'vue';
+  import type { CSSProperties } from 'vue';
+  import Tooltip from './Tooltip.vue';
+
+  interface Props {
+    maxWidth?: number | string; // 文本最大宽度
+    line?: number; // 最大行数
+    expand?: boolean; // 是否启用点击文本展开全部
+    tooltip?: boolean; // 是否启用文本提示框
+    // 以下均为 tooltip 组件属性
+    tooltipMaxWidth?: number; // 提示框内容最大宽度,单位px,默认不设置时,提示文本内容自动与展示文本宽度保持一致
+    tooltipFontSize?: number; // 提示文本字体大小,单位px,优先级高于 overlayStyle
+    tooltipColor?: string; // 提示文本字体颜色,优先级高于 overlayStyle
+    tooltipBackgroundColor?: string; // 提示框背景颜色,优先级高于 overlayStyle
+    tooltipOverlayStyle?: CSSProperties; // 提示框内容区域样式
+  }
+  const props = withDefaults(defineProps<Props>(), {
+    maxWidth: '100%',
+    line: undefined,
+    expand: false,
+    tooltip: true,
+    tooltipMaxWidth: undefined,
+    tooltipFontSize: 14,
+    tooltipColor: '#FFF',
+    tooltipBackgroundColor: 'rgba(0, 0, 0, .85)',
+    tooltipOverlayStyle: () => ({ padding: '8px 12px', textAlign: 'justify' }),
+  });
+  const textMaxWidth = computed(() => {
+    if (typeof props.maxWidth === 'number') {
+      return props.maxWidth + 'px';
+    }
+    return props.maxWidth;
+  });
+  const showTooltip = ref();
+  const ellipsis = ref();
+  const defaultTooltipMaxWidth = ref();
+  watchEffect(() => {
+    showTooltip.value = props.tooltip;
+  });
+  watchEffect(
+    () => {
+      if (props.tooltip) {
+        if (props.tooltipMaxWidth) {
+          defaultTooltipMaxWidth.value = props.tooltipMaxWidth;
+        } else {
+          defaultTooltipMaxWidth.value = ellipsis.value.offsetWidth + 24;
+        }
+      }
+    },
+    { flush: 'post' },
+  );
+  const emit = defineEmits(['expandChange']);
+  function onExpand() {
+    if (ellipsis.value.style['-webkit-line-clamp']) {
+      if (props.tooltip) {
+        showTooltip.value = false;
+        nextTick(() => {
+          ellipsis.value.style['-webkit-line-clamp'] = '';
+        });
+      } else {
+        ellipsis.value.style['-webkit-line-clamp'] = '';
+      }
+      emit('expandChange', true);
+    } else {
+      if (props.tooltip) {
+        showTooltip.value = true;
+      }
+      ellipsis.value.style['-webkit-line-clamp'] = props.line;
+      emit('expandChange', false);
+    }
+  }
+</script>
+<template>
+  <Tooltip
+    v-if="showTooltip"
+    :max-width="defaultTooltipMaxWidth"
+    :fontSize="tooltipFontSize"
+    :color="tooltipColor"
+    :backgroundColor="tooltipBackgroundColor"
+    :overlayStyle="tooltipOverlayStyle"
+  >
+    <template #tooltip>
+      <slot name="tooltip">
+        <slot></slot>
+      </slot>
+    </template>
+    <div
+      ref="ellipsis"
+      class="m-ellipsis"
+      :class="[line ? 'ellipsis-line' : 'not-ellipsis-line', { 'cursor-pointer': expand }]"
+      :style="`-webkit-line-clamp: ${line}; max-width: ${textMaxWidth};`"
+      @click="expand ? onExpand() : () => false"
+      v-bind="$attrs"
+    >
+      <slot></slot>
+    </div>
+  </Tooltip>
+  <div
+    v-else
+    ref="ellipsis"
+    class="m-ellipsis"
+    :class="[line ? 'ellipsis-line' : 'not-ellipsis-line', { 'cursor-pointer': expand }]"
+    :style="`-webkit-line-clamp: ${line}; max-width: ${textMaxWidth};`"
+    @click="expand ? onExpand() : () => false"
+    v-bind="$attrs"
+  >
+    <slot></slot>
+  </div>
+</template>
+
+<style lang="less" scoped>
+  .m-ellipsis {
+    overflow: hidden;
+    cursor: text;
+  }
+
+  .ellipsis-line {
+    display: -webkit-inline-box;
+    -webkit-box-orient: vertical;
+  }
+
+  .not-ellipsis-line {
+    display: inline-block;
+    text-overflow: ellipsis;
+    vertical-align: bottom;
+    white-space: nowrap;
+  }
+
+  .cursor-pointer {
+    cursor: pointer;
+  }
+</style>

+ 158 - 0
src/components/EllipsisText/src/Tooltip.vue

@@ -0,0 +1,158 @@
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import type { CSSProperties } from 'vue';
+  import { rafTimeout, cancelRaf } from './_utils';
+
+  interface Props {
+    maxWidth?: number; // 提示框内容最大宽度,单位px
+    content?: string; // 展示的文本 string | slot
+    tooltip?: string; // 提示的文本 string | slot
+    fontSize?: number; // 提示文本字体大小,单位px,优先级高于 overlayStyle
+    color?: string; // 提示文本字体颜色,优先级高于 overlayStyle
+    backgroundColor?: string; // 提示框背景颜色,优先级高于 overlayStyle
+    overlayStyle?: CSSProperties; // 提示框内容区域样式
+  }
+  withDefaults(defineProps<Props>(), {
+    maxWidth: 120,
+    content: '暂无内容',
+    tooltip: '暂无提示',
+    fontSize: 14,
+    color: '#FFF',
+    backgroundColor: 'rgba(0, 0, 0, .85)',
+    overlayStyle: () => ({}),
+  });
+  const visible = ref(false);
+  const hideTimer = ref();
+  const top = ref(0); // 提示框top定位
+  const left = ref(0); // 提示框left定位
+  const contentRef = ref(); // 声明一个同名的模板引用
+  const tooltipRef = ref(); // 声明一个同名的模板引用
+  function getPosition() {
+    const contentWidth = contentRef.value && contentRef.value.offsetWidth; // 展示文本宽度
+    const tooltipWidth = tooltipRef.value && tooltipRef.value.offsetWidth; // 提示文本宽度
+    const tooltipHeight = tooltipRef.value && tooltipRef.value.offsetHeight; // 提示文本高度
+    top.value = tooltipHeight + 4;
+    left.value = (tooltipWidth - contentWidth) / 2;
+  }
+  const emit = defineEmits(['openChange']);
+  function onShow() {
+    getPosition();
+    cancelRaf(hideTimer.value);
+    visible.value = true;
+    emit('openChange', visible.value);
+  }
+  function onHide(): void {
+    hideTimer.value = rafTimeout(() => {
+      visible.value = false;
+      emit('openChange', visible.value);
+    }, 100);
+  }
+</script>
+<template>
+  <div class="m-tooltip" @mouseenter="onShow" @mouseleave="onHide">
+    <div
+      ref="tooltipRef"
+      class="m-tooltip-content"
+      :class="{ 'show-tip': visible }"
+      :style="`--tooltip-font-size: ${fontSize}px; --tooltip-color: ${color}; --tooltip-background-color: ${backgroundColor}; max-width: ${maxWidth}px; top: ${-top}px; left: ${-left}px;`"
+      @mouseenter="onShow"
+      @mouseleave="onHide"
+    >
+      <div class="u-tooltip" :style="overlayStyle">
+        <slot name="tooltip">{{ tooltip }}</slot>
+      </div>
+      <div class="m-tooltip-arrow">
+        <span class="u-tooltip-arrow"></span>
+      </div>
+    </div>
+    <div ref="contentRef">
+      <slot>{{ content }}</slot>
+    </div>
+  </div>
+</template>
+
+<style lang="less" scoped>
+  .m-tooltip {
+    display: inline-block;
+    position: relative;
+
+    .m-tooltip-content {
+      position: absolute;
+      z-index: 9999;
+      width: max-content;
+      padding-bottom: 12px;
+      transform: scale(0.8); // 缩放变换
+      transform-origin: 50% 75%;
+      transition:
+        transform 0.25s,
+        opacity 0.25s;
+      opacity: 0;
+      pointer-events: none;
+
+      .u-tooltip {
+        min-width: 32px;
+        min-height: 32px;
+        padding: 6px 8px;
+        border-radius: 6px;
+        background-color: var(--tooltip-background-color);
+        box-shadow:
+          0 6px 16px 0 rgb(0 0 0 / 8%),
+          0 3px 6px -4px rgb(0 0 0 / 12%),
+          0 9px 28px 8px rgb(0 0 0 / 5%);
+        color: var(--tooltip-color);
+        font-size: var(--tooltip-font-size);
+        line-height: 1.5714;
+        text-align: start;
+        text-decoration: none;
+        word-wrap: break-word;
+      }
+
+      .m-tooltip-arrow {
+        content: '';
+        display: block;
+        position: absolute;
+        z-index: 9;
+        bottom: 12px;
+        left: 50%;
+        width: 16px;
+        height: 16px;
+        overflow: hidden;
+        transform: translateX(-50%) translateY(100%) rotate(180deg);
+        pointer-events: none;
+
+        &::before {
+          position: absolute;
+          bottom: 0;
+          inset-inline-start: 0;
+          width: 16px;
+          height: 8px;
+          background-color: var(--tooltip-background-color);
+          clip-path: path(
+            'M 0 8 A 4 4 0 0 0 2.82842712474619 6.82842712474619 L 6.585786437626905 3.0710678118654755 A 2 2 0 0 1 9.414213562373096 3.0710678118654755 L 13.17157287525381 6.82842712474619 A 4 4 0 0 0 16 8 Z'
+          );
+        }
+
+        &::after {
+          content: '';
+          position: absolute;
+          z-index: 0;
+          bottom: 0;
+          width: 8.9706px;
+          height: 8.9706px;
+          inset-inline: 0;
+          margin: auto;
+          transform: translateY(50%) rotate(-135deg);
+          border-radius: 0 0 2px;
+          background: transparent;
+          box-shadow: 3px 3px 7px rgb(0 0 0 / 10%);
+        }
+      }
+    }
+
+    .show-tip {
+      transform: scale(1); // 缩放变换
+      opacity: 1;
+      pointer-events: auto;
+    }
+  }
+</style>

+ 40 - 0
src/components/EllipsisText/src/_utils.ts

@@ -0,0 +1,40 @@
+// cancelAnimationFrame
+export const cancelAnimationFrame = window.cancelAnimationFrame;
+// 使用 requestAnimationFrame 模拟 setTimeout 和 setInterval
+export function rafTimeout(fn: Function, delay = 0, interval = false): object {
+  const requestAnimationFrame =
+    typeof window !== 'undefined' ? window.requestAnimationFrame : () => {};
+  let start: any = null;
+  function timeElapse(timestamp: number) {
+    /*
+      timestamp参数:与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻
+    */
+    if (!start) {
+      start = timestamp;
+    }
+    const elapsed = timestamp - start;
+    if (elapsed >= delay) {
+      fn(); // 执行目标函数func
+      if (interval) {
+        // 使用间歇调用
+        start = null;
+        raf.id = requestAnimationFrame(timeElapse);
+      }
+    } else {
+      raf.id = requestAnimationFrame(timeElapse);
+    }
+  }
+  const raf = {
+    // 引用类型保存,方便获取 requestAnimationFrame()方法返回的 ID.
+    id: requestAnimationFrame(timeElapse),
+  };
+  return raf;
+}
+// 用于取消 rafTimeout 函数
+export function cancelRaf(raf: { id: number }): void {
+  const cancelAnimationFrame =
+    typeof window !== 'undefined' ? window.cancelAnimationFrame : () => {};
+  if (raf && raf.id) {
+    cancelAnimationFrame(raf.id);
+  }
+}

+ 19 - 3
src/components/Form/src/components/ApiTreeSelect.vue

@@ -1,5 +1,10 @@
 <template>
-  <a-tree-select v-bind="getAttrs" @change="handleChange" :field-names="fieldNames">
+  <a-tree-select
+    v-bind="getAttrs"
+    @change="handleChange"
+    :field-names="fieldNames"
+    :load-data="async ? onLoadData : undefined"
+  >
     <template #[item]="data" v-for="item in Object.keys($slots)">
       <slot :name="item" v-bind="data || {}"></slot>
     </template>
@@ -25,12 +30,13 @@
       api: { type: Function as PropType<(arg?: Recordable<any>) => Promise<Recordable<any>>> },
       params: { type: Object },
       immediate: { type: Boolean, default: true },
+      async: { type: Boolean, default: false },
       resultField: propTypes.string.def(''),
       labelField: propTypes.string.def('title'),
       valueField: propTypes.string.def('value'),
       childrenField: propTypes.string.def('children'),
     },
-    emits: ['options-change', 'change'],
+    emits: ['options-change', 'change', 'load-data'],
     setup(props, { attrs, emit }) {
       const treeData = ref<Recordable<any>[]>([]);
       const isFirstLoaded = ref<Boolean>(false);
@@ -70,6 +76,16 @@
         props.immediate && fetch();
       });
 
+      function onLoadData(treeNode) {
+        return new Promise((resolve: (value?: unknown) => void) => {
+          if (isArray(treeNode.children) && treeNode.children.length > 0) {
+            resolve();
+            return;
+          }
+          emit('load-data', { treeData, treeNode, resolve });
+        });
+      }
+
       async function fetch() {
         const { api } = props;
         if (!api || !isFunction(api) || loading.value) return;
@@ -90,7 +106,7 @@
         isFirstLoaded.value = true;
         emit('options-change', treeData.value);
       }
-      return { getAttrs, loading, handleChange, fieldNames };
+      return { getAttrs, loading, handleChange, fieldNames, onLoadData };
     },
   });
 </script>

+ 1 - 0
src/locales/lang/en/routes/demo.json

@@ -62,6 +62,7 @@
     "clickOutSide": "ClickOutSide", 
     "imgPreview": "Picture Preview", 
     "copy": "Clipboard", 
+    "ellipsis": "EllipsisText", 
     "msg": "Message prompt", 
     "watermark": "Watermark", 
     "ripple": "Ripple", 

+ 1 - 0
src/locales/lang/zh-CN/routes/demo.json

@@ -62,6 +62,7 @@
     "clickOutSide": "ClickOutSide组件", 
     "imgPreview": "图片预览", 
     "copy": "剪切板", 
+    "ellipsis": "文本省略", 
     "msg": "消息提示", 
     "watermark": "水印", 
     "ripple": "水波纹", 

+ 8 - 0
src/router/routes/modules/demo/feat.ts

@@ -173,6 +173,14 @@ const feat: AppRouteModule = {
       },
     },
     {
+      path: 'ellipsis',
+      name: 'EllipsisDemo',
+      component: () => import('/@/views/demo/feat/ellipsis/index.vue'),
+      meta: {
+        title: t('routes.demo.feat.ellipsis'),
+      },
+    },
+    {
       path: 'msg',
       name: 'MsgDemo',
       component: () => import('/@/views/demo/feat/msg/index.vue'),

+ 47 - 0
src/views/demo/feat/ellipsis/index.vue

@@ -0,0 +1,47 @@
+<template>
+  <PageWrapper title="文本省略示例">
+    <CollapseContainer class="w-full bg-white rounded-md mb-4" title="Ellipsis 基本使用">
+      <EllipsisText :maxWidth="240">{{ text }}</EllipsisText>
+    </CollapseContainer>
+    <CollapseContainer class="w-full bg-white rounded-md mb-4" title="Ellipsis 多行省略">
+      <EllipsisText :line="2">{{ text }}</EllipsisText>
+    </CollapseContainer>
+    <CollapseContainer class="w-full bg-white rounded-md mb-4" title="Ellipsis 点击展开">
+      <EllipsisText expand :line="3">{{ text }}</EllipsisText>
+    </CollapseContainer>
+    <CollapseContainer class="w-full bg-white rounded-md mb-4" title="Ellipsis 定制 Tooltip 内容">
+      <EllipsisText :max-width="240">
+        住在我心里孤独的 孤独的海怪 痛苦之王 开始厌倦 深海的光 停滞的海浪
+        <template #tooltip>
+          <div style="text-align: center">
+            《秦皇岛》<br />住在我心里孤独的<br />孤独的海怪 痛苦之王<br />开始厌倦 深海的光
+            停滞的海浪
+          </div>
+        </template>
+      </EllipsisText>
+    </CollapseContainer>
+  </PageWrapper>
+</template>
+<script lang="ts">
+  import { defineComponent, ref } from 'vue';
+  import { CollapseContainer } from '/@/components/Container/index';
+  import { PageWrapper } from '/@/components/Page';
+  import { EllipsisText } from '@/components/EllipsisText';
+
+  export default defineComponent({
+    name: 'Ellipsis',
+    components: { CollapseContainer, PageWrapper, EllipsisText },
+    setup() {
+      const text =
+        ref(`Vue-Vben-Admin 是一个基于 Vue3.0、Vite、 Ant-Design-Vue、TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。
+      包括二次封装组件、utils、hooks、动态菜单、权限校验、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。
+      也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。Vue-Vben-Admin 是一个基于 Vue3.0、Vite、 Ant-Design-Vue、TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。
+      包括二次封装组件、utils、hooks、动态菜单、权限校验、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。
+      也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。Vue-Vben-Admin 是一个基于 Vue3.0、Vite、 Ant-Design-Vue、TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。
+      包括二次封装组件、utils、hooks、动态菜单、权限校验、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。
+      也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。`);
+
+      return { text };
+    },
+  });
+</script>

+ 43 - 0
src/views/demo/form/index.vue

@@ -464,6 +464,49 @@
       },
     },
     {
+      field: 'field33',
+      component: 'ApiTreeSelect',
+      label: '远程懒加载下拉树',
+      helpMessage: ['ApiTreeSelect组件', '使用接口提供的数据生成选项'],
+      required: true,
+      componentProps: {
+        api: () => {
+          return new Promise((resolve) => {
+            resolve([
+              {
+                title: 'Parent Node',
+                value: '0-0',
+              },
+            ]);
+          });
+        },
+        async: true,
+        onChange: (e, v) => {
+          console.log('ApiTreeSelect====>:', e, v);
+        },
+        onLoadData: ({ treeData, resolve, treeNode }) => {
+          console.log('treeNode====>:', treeNode);
+          setTimeout(() => {
+            const children: Recordable[] = [
+              { title: `Child Node ${treeNode.eventKey}-0`, value: `${treeNode.eventKey}-0` },
+              { title: `Child Node ${treeNode.eventKey}-1`, value: `${treeNode.eventKey}-1` },
+            ];
+            children.forEach((item) => {
+              item.isLeaf = false;
+              item.children = [];
+            });
+            treeNode.dataRef.children = children;
+            treeData.value = [...treeData.value];
+            resolve();
+            return;
+          }, 300);
+        },
+      },
+      colProps: {
+        span: 8,
+      },
+    },
+    {
       field: 'field34',
       component: 'ApiRadioGroup',
       label: '远程Radio',