普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月20日技术

在 Vue 3.5 中优雅地集成 wangEditor,并定制“AI 工具”下拉菜单(总结/润色/翻译)

2025年10月20日 17:42
在内容创作场景中,富文本编辑器与 AI 能力的结合高效且轻量化的组合。这篇文章以 wangEditor 在 Vue 3 中的集成为基础,分享一套低耦合、高可扩展性的方案,核心目标是:如何优雅地新增一个

🍀继分页器组件后,封装了个抽屉组件

作者 H_四叶草
2025年10月20日 17:18

Drawer 组件

效果图

动画.gif

使用

<template>
 <Button @click="openDrawer1">从右边显示抽屉</Button>
 <Button @click="openDrawer2">从左边显示抽屉</Button>
 <Button @click="openDrawer3">从上边显示抽屉</Button>
 <Button @click="openDrawer4">从下边显示抽屉</Button>

 <Drawer v-model="showDrawer1" direction="right" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer2" direction="left" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer3" direction="top" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer4" direction="bottom" title="抽屉组件"></Drawer>
</template>
<script lang="ts" setup>
const showDrawer1 = ref(false);
const showDrawer2 = ref(false);
const showDrawer3 = ref(false);
const showDrawer4 = ref(false);
const openDrawer1 = () => {
  showDrawer1.value = true
}
const openDrawer2 = () => {
  showDrawer2.value = true
}
const openDrawer3 = () => {
  showDrawer3.value = true
}
const openDrawer4 = () => {
  showDrawer4.value = true
}
</script>

一、组件目录结构

|-Drawer
   |--Drawer.vue(组件)
   |--style.css(样式)
   |--types.ts(类型定义)
   |--index.ts(入口文件)

二、Drawer.vue

<template>
  <Overlay :modelValue="modelValue" :modalClass="modalClass">
    <Transition :name="transitionName">
      <div
        ref="drawerRef"
        class="vh-drawer"
        :class="[`vh-drawer--${direction}`, customClass]"
        :style="drawerStyle"
        v-if="modelValue"
      >
        <div class="vh-drawer__header" v-if="title || $slots.header">
          <slot name="header">{{ title }}</slot>
          <Icon class="vh-drawer__close" icon="xmark" v-if="showClose" @click.stop="handleClose" />
        </div>
        <div class="vh-drawer__body">
          <slot />
        </div>
        <div class="vh-drawer__footer" v-if="$slots.footer">
          <slot name="footer" />
        </div>
      </div>
    </Transition>
  </Overlay>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import Icon from '../Icon/Icon.vue'
import Overlay from '../Overlay/Overlay.vue'
import type { DrawerProps, DrawerEmits } from './types'
import { isFunction } from 'lodash-es'

defineOptions({ name: 'vhDrawer' })

const props = withDefaults(defineProps<DrawerProps>(), {
  direction: 'right',
  size: '30%',
  title: '',
  showClose: true,
  closeOnClickModal: true,
  closeOnPressEscape: true,
  beforeClose: undefined,
  customClass: '',
  modal: true,
  modalClass: '',
  openDelay: 0,
  closeDelay: 0
})

const emits = defineEmits<DrawerEmits>()

const drawerRef = ref<HTMLElement>()
const isVisible = ref(false)

// 计算抽屉样式
const drawerStyle = computed(() => {
  const style: Record<string, string> = {}
  const isHorizontal = ['left', 'right'].includes(props.direction)

  if (isHorizontal) {
    style.width = typeof props.size === 'number' ? `${props.size}px` : props.size
  } else {
    style.height = typeof props.size === 'number' ? `${props.size}px` : props.size
  }

  return style
})

// 根据方向计算过渡动画名称
const transitionName = computed(() => {
  return `vh-drawer-${props.direction}`
})

// 关闭抽屉
const handleClose = () => {
  if (isFunction(props.beforeClose)) {
    props.beforeClose(() => {
      emits('update:modelValue', false)
    })
  } else {
    emits('update:modelValue', false)
  }
}

// 处理点击外部关闭
const handleWrapperClick = (e: MouseEvent) => {
  if (e.target === e.currentTarget && props.closeOnClickModal) {
    handleClose()
  }
}

// 处理ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
  if (e.key === 'Escape' && props.closeOnPressEscape) {
    handleClose()
  }
}

// 过渡动画钩子
const handleBeforeEnter = () => {
  isVisible.value = true
  emits('open')
}

const handleAfterEnter = () => {
  emits('opened')
}

const handleBeforeLeave = () => {
  emits('close')
}

const handleAfterLeave = () => {
  isVisible.value = false
  emits('closed')
}

// 监听显示状态
watch(
  () => props.modelValue,
  (newVal) => {
    if (newVal) {
      nextTick(() => {
        document.addEventListener('keydown', handleKeydown)
      })
    } else {
      document.removeEventListener('keydown', handleKeydown)
    }
  }
)

// 组件卸载时清理事件监听
onBeforeUnmount(() => {
  document.removeEventListener('keydown', handleKeydown)
})
</script>

types.ts

import type { PropType } from 'vue'

export type PaginationLayout = string

export interface PaginationProps {
  // 当前页码
  pageNum?: number
  // 每页显示条数
  pageSize?: number
  // 总条数
  total: number
  // 每页显示条数选择器的选项
  pageSizes?: number[]export interface DrawerProps {
    modelValue: boolean;
    direction?: 'left' | 'right' | 'top' | 'bottom';
    size?: number | string;
    title?: string;
    showClose?: boolean;
    closeOnClickModal?: boolean;
    closeOnPressEscape?: boolean;
    beforeClose?: (done: () => void) => void;
    customClass?: string;
    modal?: boolean;
    modalClass?: string;
    openDelay?: number;
    closeDelay?: number;
}

export interface DrawerEmits {
  (e: 'update:modelValue', value: boolean): void;
  (e: 'open'): void;
  (e: 'opened'): void;
  (e: 'close'): void;
  (e: 'closed'): void;
}
  // 布局配置 (total, sizes, prev, pager, next, jumper)
  layout?: PaginationLayout
  // 背景是否显示
  background?: boolean
  // 是否禁用
  disabled?: boolean
  // 是否显示小型分页
  small?: boolean
  // 页码按钮的数量,当总页数超过该值时会折叠
  pagerCount?: number
  // 上一页按钮的文本
  prevText?: string
  // 下一页按钮的文本
  nextText?: string
  // 省略时显示的内容
  ellipsisText?: string
  // 自定义跳转内容
  jumper?: boolean
  // 自定义大小选择器的内容
  sizeSelector?: boolean
  // 自定义总条数显示的内容
  totalSelector?: boolean
}

// 事件类型定义
export interface PaginationEmits {
  (e: 'update:pageNum', value: number): void
  (e: 'update:pageSize', value: number): void
  (e: 'size-change', size: number): void
  (e: 'current-change', current: number): void
  (e: 'prev-click', current: number): void
  (e: 'next-click', current: number): void
}

index.ts

import type { App } from 'vue'
import Drawer from './Drawer.vue'

Drawer.install = (app: App) => {
  app.component(Drawer.name, Drawer)
}

export default Drawer

export * from './types'

style.css

.vh-drawer__wrapper {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 2000;
    overflow: hidden;
  }
  
  .vh-drawer {
    position: fixed;
    background-color: var(--vh-bg-color);
    transition: all var(--vh-transition-duration) ease;
    box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
    display: flex;
    flex-direction: column;
    z-index: 2001;
  
    &--left {
      top: 0;
      left: 0;
      bottom: 0;
      border-right: var(--vh-border);
    }
  
    &--right {
      top: 0;
      right: 0;
      bottom: 0;
      border-left: var(--vh-border);
    }
  
    &--top {
      top: 0;
      left: 0;
      right: 0;
      border-bottom: var(--vh-border);
    }
  
    &--bottom {
      bottom: 0;
      left: 0;
      right: 0;
      border-top: var(--vh-border);
    }
  }
  
  .vh-drawer__header {
    padding: 20px 24px;
    border-bottom: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: var(--vh-font-size-large);
    font-weight: var(--vh-font-weight-primary);
    color: var(--vh-text-color-primary);
  }
  
  .vh-drawer__close {
    color: var(--vh-text-color-regular);
    font-size: var(--vh-font-size-large);
    cursor: pointer;
    transition: color var(--vh-transition-duration);
  
    &:hover {
      color: var(--vh-text-color-primary);
    }
  }
  
  .vh-drawer__body {
    flex: 1;
    padding: 24px;
    overflow-y: auto;
  }
  
  .vh-drawer__footer {
    padding: 16px 24px;
    border-top: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
  }
  
  .vh-drawer-right-enter-from {
    transform: translateX(100%);
  }
  
  .vh-drawer-right-leave-to {
    transform: translateX(100%);
  }
  
  /* 左侧抽屉动画 */
  .vh-drawer-left-enter-from {
    transform: translateX(-100%);
  }
  
  .vh-drawer-left-leave-to {
    transform: translateX(-100%);
  }
  
  /* 顶部抽屉动画 */
  .vh-drawer-top-enter-from {
    transform: translateY(-100%);
  }
  
  .vh-drawer-top-leave-to {
    transform: translateY(-100%);
  }
  
  /* 底部抽屉动画 */
  .vh-drawer-bottom-enter-from {
    transform: translateY(100%);
  }
  
  .vh-drawer-bottom-leave-to {
    transform: translateY(100%);
  }

其他文章

# 🍀上班摸鱼,手搓了个分页器组件

# 我很好奇客户会用得懂这个组件吗

#【🍀新鲜出炉 】十个 “如何”从零搭建 Nuxt3 项目

# 图解封装多种数据结构(栈、队列、优先级队列、链表、双向链表、二叉树)

# 面试官提问:为什么表单提交不会出现跨域

# 【前端】整理了一些网络相关面试题(🍀拿走不谢🍀)

# 学完 Pinia 真香,不想用 vuex 了

# 非标题党:前端项目编程规范化配置(大厂规范)

❌
❌