(学习笔记)前中台项目学习-组件库

120 阅读8分钟

为什么在这个项目要自己构建组件库

本项目是前台系统,前台讲究个性化,后台偏同质化,这就导致了市面上的组件库都是针对后台的,而前台系统大多不适用,所以要自己构建一个组件库。

当然,在实际工作中,许多组件非常复杂,比如日期组件,自己写的成本极高,我们还是会用到一些UI框架基础组件,配置他们一定的定制能力,改造成项目所需要的。我们要自己写的,通常更多的是,项目中的业务组件,在本项目的业务通用。

组件库架构搭建

组件库一般建立一个libs文件夹

├── libs // 通用组件,可用于构建中台物料库或通用组件库
│   ├── svg-icon // 每一个组件建立一个文件夹
│   └── index.js // 把所有组件导出和注册

在libs/index.js中注册所有组件

import svgIcon from './svg-icon/index.vue'

export default {
  install(app) {
    app.component('m-svg-icon', svgIcon)
    ...
  }
}

svg-icon组件

组件功能

  1. 通过传入name就能显示svg图片
  2. 期望有一个color参数,能直接配置颜色
  3. 期望能传入一个自定义的class,实现定制化

实现代码

<template>
  <svg aria-hidden="true">
    <use :class="fillClass" :xlink:href="symbolId" :fill="color" />
  </svg>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  // 显示的 svg 图标名称(剔除 icon-)
  name: {
    type: String,
    required: true
  },
  // 直接指定 svg 图标的颜色
  color: {
    type: String
  },
  // 通过 tailwind 指定 svg 颜色的类名
  fillClass: {
    type: String
  }
})
// 真实显示的 svg 图标名(拼接 #icon-)
const symbolId = computed(() => `#icon-${props.name}`)
</script>

vite要解析svg图片还需要引入插件createSvgIconsPlugin

import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

  plugins: [
    vue(),
    createSvgIconsPlugin({
      // 指定需要缓存的图标文件夹
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // 指定symbolId格式
      symbolId: 'icon-[name]'
    })
  ],

以及在mian.js中引入

import 'virtual:svg-icons-register'

弹出窗口-popup组件

image.png

popup组件能力分析

  1. popup 展开时,内容视图应该不属于任何一个 组件内部 ,而应该直接被插入到 body 下面
  2. popup 应该包含两部分内容,一部分为背景蒙版,一部分为内容的包裹容器
  3. popup 应该通过一个双向绑定进行控制展示和隐藏
  4. popup 展示时,滚动应该被锁定
  5. 内容区域应该接收所有的  attrs,并且应该通过插槽让调用方指定其内容

前置知识

Vue3内置组件Teleport
该组件可以把一段dom append到某个元素内

使用方式示范

<Teleport to="body">
    <div v-if="open" class="modal">
        <p>Hello from the modal!</p> <button @click="open = false">Close</button>
    </div>
</Teleport>

vue过度动画组件Transition
cn.vuejs.org/guide/built…

vue3的组件双向数据绑定

// 父组件
<Child v-model="count" />

// 子组件
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

从vue3.4开始,推荐使用defineModel方法实现双向数据绑定 cn.vuejs.org/guide/compo…

实现代码

<template>
  <div class="">
    <!-- teleport -->
    <teleport to="body">
      <!-- 蒙版 -->
      <transition name="fade">
        <div
          v-if="modelValue"
          class="w-screen h-screen bg-zinc-900/80 z-40 fixed top-0 left-0"
          @click="emits('update:modelValue', false)"
        ></div>
      </transition>
      <!-- 内容 -->
      <transition name="popup-down-up">
        <div
          v-if="modelValue"
          v-bind="$attrs"
          class="w-screen bg-white z-50 fixed bottom-0"
        >
          <slot />
        </div>
      </transition>
    </teleport>
  </div>
</template>

<script setup>
import { useScrollLock } from '@vueuse/core'
import { watch } from 'vue'
const props = defineProps({
  modelValue: {
    required: true,
    type: Boolean
  }
})

const emits = defineEmits(['update:modelValue'])

// ------ 滚动锁定 ------
const isLocked = useScrollLock(document.body)
watch(
  () => props.modelValue,
  (val) => {
    isLocked.value = val
  },
  {
    immediate: true
  }
)
</script>

<style lang="scss" scoped>
// fade 展示动画
.fade-enter-active {
  transition: all 0.3s;
}

.fade-leave-active {
  transition: all 0.3s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
// popup-down-up 展示动画
.popup-down-up-enter-active {
  transition: all 0.3s;
}

.popup-down-up-leave-active {
  transition: all 0.3s;
}

.popup-down-up-enter-from,
.popup-down-up-leave-to {
  transform: translateY(100%);
}
</style>

button组件

能力分析

  1. 可以显示文字按钮,并提供 loading 功能
  2. 可以显示 icon 按钮,并可以任意指定 icon 颜色
  3. 可开关的点击动画
  4. 可以指定各种风格和大小
  5. 当指定的风格或大小不符合预设时,需要给开发者以提示消息

实现思路

html结构
只需要一个button,button配置有icon,或者文字就行,不需要别的

<button>
    // icon 组件
    <icon v-if="显示icon">
    // loading
    <icon v-if="显示loading">
    // 配置按钮文字
    <slot>
</button>

分析需要设置哪些props

  1. type:需要指定风格,该字段要校验只能输入指定的字符串
  2. size:需要指定大小,该字段要校验只能输入指定的字符串
  3. icon:是否显示icon
  4. iconColor:指定icon的颜色
  5. iconClass:给icon类型,方面定制样式
  6. isActiveAnim:按钮点击时候,是否需要动画
  7. loading:是否需要loading
  8. 有一个默认slot,用来配置按钮的文字的

type按钮有几种风格,每一种风格是什么样式,应该预设定义好

// type 可选项:表示按钮风格
const typeEnum = {
  primary: 'text-white  bg-zinc-800 hover:bg-zinc-900 active:bg-zinc-800 ',
  main: 'text-white  bg-main hover:bg-hover-main active:bg-main ',
  info: 'text-zinc-800 bg-zinc-200 hover:bg-zinc-300 active:bg-zinc-200 '
}

size按钮大小一共有大小,每一种是多大,预设好

// size 可选项:表示按钮大小。区分文字按钮和icon按钮
const sizeEnum = {
  default: {
    button: 'w-8 h-4 text-base',
    icon: ''
  },
  'icon-default': {
    button: 'w-4 h-4',
    icon: 'w-1.5 h-1.5'
  },
  small: {
    button: 'w-7 h-3 text-base',
    icon: ''
  },
  'icon-small': {
    button: 'w-3 h-3',
    icon: 'w-1.5 h-1.5'
  }
}

search搜索框组件

image.png

能力分析

  1. 输入内容实现双向数据绑定
  2. 鼠标移入与获取焦点时的动画
  3. 一键清空文本功能
  4. 搜索触发功能
  5. 可控制,可填充的下拉展示区
  6. 监听到以下事件列表:
    1. clear:删除所有文本事件
    2. input:输入事件
    3. focus:获取焦点事件
    4. blur:失去焦点事件
    5. search:触发搜索(点击或回车)事件

html基本结构
布局细节,输入框左边有icon,右边也有icon,好的做法是,左边一个元素(icon),右边一个元素(div),然后中间才是input输入框,而不是用什么css before之类的

<template>
    <div ref="containerTarget" class="group relative p-0.5 rounded-xl border-white duration-500 hover:bg-red-100/40">
        <div>
            <!-- 搜索图标 -->
            <m-svg-icon class="w-1.5 h-1.5 absolute translate-y-[-50%] top-[50%] left-2" name="search" color="#707070" />
            <!-- 输入框 -->
            <input v-model="inputValue"
                class="block w-full h-[44px] pl-4 text-sm outline-0 bg-zinc-100 caret-zinc-400 rounded-xl text-zinc-900 tracking-wide font-semibold border border-zinc-100 duration-500 group-hover:bg-white group-hover:border-zinc-200 focus:border-red-300"
                type="text" placeholder="搜索" @keyup.enter="onSearchHandler" @focus="onFocusHandler" @blur="onBlurHandler" />
            <!-- 删除按钮 -->
            <m-svg-icon name="input-delete" v-show="inputValue" @click="onClearClick"
                class="h-1.5 w-1.5 absolute translate-y-[-50%] top-[50%] right-9 duration-500 cursor-pointer"></m-svg-icon>
            <!-- 分割线 -->
            <div
                class="opacity-0 h-1.5 w-[1px] absolute translate-y-[-50%] top-[50%] right-[62px] duration-500 bg-zinc-200 group-hover:opacity-100">
            </div>
            <!-- TODO: 搜索按钮(通用组件) -->
            <m-button @click="onSearchHandler"
                class="group-hover:opacity-100 opacity-0 duration-500 absolute translate-y-[-50%] top-[50%] right-1 rounded-full"
                icon="search" iconColor="#ffffff"></m-button>
        </div>
        <!-- 下拉区 -->
        <transition name="slide">
            <div v-if="$slots.dropdown" v-show="isFocus"
                class="max-h-[368px] w-full text-base overflow-auto bg-white absolute z-20 left-0 top-[56px] p-2 rounded border border-zinc-200 duration-200 hover:shadow-3xl">
                <slot name="dropdown" />
            </div>
        </transition>
    </div>
</template>

实现思路

  1. 输入内容实现双向数据绑定
  2. 搜索按钮在 hover 时展示,并为圆角:
  3. 一键清空文本的功能,存在文本的时候才会展示
  4. 点击搜索按钮,触发搜索
  5. 可控制,可填充的下拉展示区
  6. 处理所有事件通知
<script>
const EMIT_UPDATE_MODELVALUE = 'update:modelValue'

// 触发搜索(点击或回车)事件
const EMIT_SEARCH = 'search'
// 删除所有文本事件
const EMIT_CLEAR = 'clear'
// 输入事件
const EMIT_INPUT = 'input'
// 获取焦点事件
const EMIT_FOCUS = 'focus'
// 失去焦点事件
const EMIT_BLUR = 'blur'
</script>
<script setup>
import { defineEmits, watch, ref } from 'vue'
import { useVModel, onClickOutside } from '@vueuse/core'

const props = defineProps({
    modelValue: {
        require: true,
        type: String
    }
})
const inputValue = useVModel(props)
// 监听用户输入行为
watch(inputValue, (val) => {
    emits(EMIT_INPUT, val)
})
const emits = defineEmits([EMIT_UPDATE_MODELVALUE, EMIT_SEARCH, EMIT_CLEAR, EMIT_INPUT, EMIT_FOCUS, EMIT_BLUR])
// 清空
const onClearClick = () => {
    inputValue.value = ''
    emits(EMIT_CLEAR)
}
// 触发搜索
const onSearchHandler = () => {
    emits(EMIT_SEARCH, inputValue.value)
}
// 聚焦出现下拉
const isFocus = ref(false)
const onFocusHandler = () => {
    emits(EMIT_FOCUS)
    isFocus.value = true
}
// 失去焦点
const onBlurHandler = () => {
    emits(EMIT_BLUR)
}
// 点击区域外隐藏
const containerTarget = ref()
onClickOutside(containerTarget, () => {
    isFocus.value = false
})
</script>

popover组件

能力分析

  1. 具备两个插槽:
    1. 具名插槽:用于表示触发弹出层的视图
    2. 匿名插槽:用来表示弹出层视图中展示的内容
  2. 控制弹出层的位置,我们期望可以具备以下位置弹出:左上,右上,左下,右下

需要的props 1.

实现思路

如何知道弹出框应该显示在什么位置?
该组件最外层外层设置为relative,气泡元素设置为absolute,所以默认位置如图:
image.png
一共有四个位置需要显示:左上,右上,左下,右下
image.png
根据两张图可知

  1. 左上位置:left:-气泡元素的width;top:0
  2. 右上位置:left:触发元素的width;top:0
  3. 左下位置:left:-气泡元素的width;top:-触发元素的height
  4. 左上位置:left:触发元素的width;top:-触发元素的height

所以实现思路是:监听气泡是否显示,每一次气泡显示的时候,根据以上公式计算出气泡的位置。

实现代码

<template>
    <div class="relative" @mouseleave="onMouseleave" @mouseenter="onMouseenter">
        <div ref="referenceTarget">
            <!-- 具名插槽 -->
            <slot name="reference" />
        </div>
        <!-- 气泡展示动画 -->
        <transition name="slide">
            <div ref="contentTarget" :style="contentStyle" v-if="isVisable"
                class="absolute p-1 z-20 bg-white border rounded-md">
                <!-- 匿名插槽 -->
                <slot />
            </div>
        </transition>
    </div>
</template>
<script>
const PROP_TOP_LEFT = 'top-left'
const PROP_TOP_RIGHT = 'top-right'
const PROP_BOTTOM_LEFT = 'bottom-left'
const PROP_BOTTOM_RIGHT = 'bottom-right'
// 延迟关闭时长
const DELAY_TIME = 100
// 定义指定位置的 Enum
const placementEnum = [
    PROP_TOP_LEFT,
    PROP_TOP_RIGHT,
    PROP_BOTTOM_LEFT,
    PROP_BOTTOM_RIGHT
]
</script>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
    // 控制气泡弹出位置,并给出开发者错误的提示
    placement: {
        type: String,
        default: 'bottom-left',
        validator(val) {
            const result = placementEnum.includes(val)
            if (!result) {
                throw new Error(
                    `你的 placement 必须是 ${placementEnum.join('、')} 中的一个`
                )
            }
            return result
        }
    }
})
// 控制 menu 展示
const isVisable = ref(false)

// 控制延迟关闭
let timeout = null
/**
 * 鼠标移入的触发行为
 */
const onMouseenter = () => {
    isVisable.value = true
    // 再次触发时,清理延时装置
    if (timeout) {
        clearTimeout(timeout)
    }
}
/**
 * 鼠标移出的触发行为
 */
const onMouseleave = () => {
    // 延时装置
    timeout = setTimeout(() => {
        isVisable.value = false
        timeout = null
    }, DELAY_TIME)
}

/**
 * 计算弹层位置
 */
const contentStyle = ref({
    top: 0,
    left: 0
})
const referenceTarget = ref()
const contentTarget = ref()
const useElementSize = (el) => {
    if (!el) {
        return {}
    }
    return {
        width: el.offsetWidth,
        height: el.offsetHeight,
    }
}
/**
 * 监听展示的变化,在展示时计算气泡位置
 */
watch(isVisable, (val) => {
    if (!val) {
        return
    }
    // 等待渲染成功之后
    nextTick(() => {
        switch (props.placement) {
            // 左上
            case PROP_TOP_LEFT:
                contentStyle.value.top = 0
                contentStyle.value.left =
                    -useElementSize(contentTarget.value).width + 'px'
                break
            // 右上
            case PROP_TOP_RIGHT:
                contentStyle.value.top = 0
                contentStyle.value.left =
                    useElementSize(referenceTarget.value).width + 'px'
                break
            // 左下
            case PROP_BOTTOM_LEFT:
                contentStyle.value.top =
                    useElementSize(referenceTarget.value).height + 'px'
                contentStyle.value.left =
                    -useElementSize(contentTarget.value).width + 'px'
                break
            // 右下
            case PROP_BOTTOM_RIGHT:
                contentStyle.value.top =
                    useElementSize(referenceTarget.value).height + 'px'
                contentStyle.value.left =
                    useElementSize(referenceTarget.value).width + 'px'
                break
        }
    })
})
</script>
  
<style lang="scss" scoped>
// slide 展示动画
.slide-enter-active {
    transition: opacity 0.3s, transform 0.3s;
}

.slide-leave-active {
    transition: opacity 0.3s, transform 0.3s;
}

.slide-enter-from,
.slide-leave-to {
    transform: translateY(20px);
    opacity: 0;
}
</style>

倒计时组件

能力分析

我们希望,倒计时组件,除了倒计时这个功能,其他一切都是可以配置的,所以要有以下设计

  1. 界面显示用一个slot,把显示什么东西完全交给用户,如果给个时间就行。
  2. 倒计时结束需要通知用户,有一个finish事件
  3. 倒计时每更新一秒,都通知一次用户,有一个change事件
  4. 在组件销毁的时候,要停止记时,防止内存泄漏
  5. 需要传两个参数
    1. 时间(毫秒)
    2. 时间格式

核心代码

<template>
    <div>
        <slot>
            <p class="text-sm">
                {{ showTime }}
            </p>
        </slot>
    </div>
</template>
<script>
// 倒计时结束
const EMITS_FINISH = 'finish'
// 倒计时改变
const EMITS_CHANGE = 'change'

const INTERVAL_COUNT = 1000

</script>
<script setup>
import { ref, watch, onUnmounted, computed } from 'vue'
import dayjs from './utils'

const emits = defineEmits([EMITS_FINISH, EMITS_CHANGE])

const props = defineProps({
    // 毫秒
    time: {
        type: Number,
        required: true
    },
    // 遵循 dayjs format 标准:https://day.js.org/docs/zh-CN/parse/string-format
    format: {
        type: String,
        default: 'HH:mm:ss'
    }
})

/**
 * 开始倒计时
 */
const start = () => {
    close()
    interval = setInterval(() => {
        durationFn()
    }, INTERVAL_COUNT)
}
// 倒计时时长
const duration = ref(0)
/**
 * 倒计时行为
 */
const durationFn = () => {
    duration.value -= INTERVAL_COUNT
    emits(EMITS_CHANGE)
    // 监听结束行为
    if (duration.value <= 0) {
        duration.value = 0
        emits(EMITS_FINISH)
        close()
    }
}

/**
 * 清理倒计时
 */
let interval = null
const close = () => {
    if (interval) {
        clearInterval(interval)
    }
}

/**
 * 开始倒计时
 */
watch(
    () => props.time,
    (val) => {
        duration.value = val
        start()
    },
    {
        immediate: true
    }
)

/**
 * 组件销毁时,清理倒计时
 */
onUnmounted(() => {
    close()
})

/**
 * 处理显示时间
 */
const showTime = computed(() => {
    return dayjs.duration(duration.value).format(props.format)
})
</script>

<style></style>

confirm组件

image.png

这样一个确认弹窗组件,核心功能是要实现通过方法调用,而不是通过标签调用。

   confirm('要删除所有历史记录吗?').then(() => {
       // TODO...
   })

前置知识-h()函数和render()函数

h函数本质是createVnode函数,用于创建vnode,接受三个参数 (要渲染的 dom,attrs 对象,子元素)

render函数的本质是把vnode转化为真实的dom,并append到指定的某个元素中

所以实现通过函数调用组件的思路是:

  1. 创建一个comfirm.vue组件
  2. 创建comfirm函数,
  3. 在comfirm函数内:
    1. 把comfirm.vue组件当h函数的参数,执行h函数生成vnode,
    2. 然后再vnode当成参数,执行render函数,这样就把comfirm.vue组件渲染到页面中了

confirm组件能力分析

我们希望这个组件以下内容可以定制,通过传参传入实现配置:

  1. 标题
  2. 内容
  3. 取消按钮文本
  4. 确定按钮文本
  5. 取消按钮事件
  6. 确定按钮事件
  7. 关闭弹窗的回调
const props = defineProps({
  // 标题
  title: {
    type: String
  },
  // 描述
  content: {
    type: String,
    required: true
  },
  // 取消按钮文本
  cancelText: {
    type: String,
    default: '取消'
  },
  // 确定按钮文本
  confirmText: {
    type: String,
    default: '确定'
  },
  // 取消按钮事件
  cancelHandler: {
    type: Function
  },
  // 确定按钮事件
  confirmHandler: {
    type: Function
  },
  // 关闭 confirm 的回调
  close: {
    type: Function
  }
})

代码实现confirm组件

<template>
    <div>
        <!-- 蒙版 -->
        <transition name="fade">
            <div v-if="isVisable" @click="close" class="w-screen h-screen bg-zinc-900/80 z-40 fixed top-0 left-0"></div>
        </transition>
        <!-- 内容 -->
        <transition name="up">
            <div v-if="isVisable"
                class="w-[80%] fixed top-1/3 left-[50%] translate-x-[-50%] z-50 px-2 py-1.5 rounded-sm border dark:border-zinc-600 cursor-pointer bg-white dark:bg-zinc-800 xl:w-[35%]">
                <!-- 标题 -->
                <div class="text-lg font-bold text-zinc-900 dark:text-zinc-200 mb-2">
                    {{ title }}
                </div>
                <!-- 内容 -->
                <div class="text-base text-zinc-900 dark:text-zinc-200 mb-2">
                    {{ content }}
                </div>
                <!-- 按钮 -->
                <div class="flex justify-end">
                    <m-button type="info" class="mr-2" @click="onCancelClick">{{
                        cancelText
                    }}</m-button>
                    <m-button type="primary" @click="onConfirmClick">{{
                        confirmText
                    }}</m-button>
                </div>
            </div>
        </transition>
    </div>
</template>
<script setup>
import mButton from '../button/index.vue'
import { ref, onMounted } from 'vue'
const props = defineProps({
     // ....
})

// 控制显示处理
const isVisable = ref(false)

/**
 * confirm 展示
 */
const show = () => {
    isVisable.value = true
}

/**
 * 页面构建完成之后,执行。保留动画
 */
onMounted(() => {
    show()
})

// 关闭动画执行时间
const duration = '0.5s'
/**
 * confirm 关闭,保留动画执行时长
 */
const close = () => {
    isVisable.value = false
    setTimeout(() => {
        if (props.close) {
            props.close()
        }
    }, parseInt(duration.replace('0.', '').replace('s', '')) * 100)
}

/**
 * 取消按钮点击事件
 */
const onCancelClick = () => {
    if (props.cancelHandler) {
        props.cancelHandler()
    }
    close()
}

/**
 * 确定按钮点击事件
 */
const onConfirmClick = () => {
    if (props.confirmHandler) {
        props.confirmHandler()
    }
    close()
}
</script>

注意细节:
因为需要保留执行动画,所以我们在 mounted 时,再让内容展示,否则没有动画

代码实现confirm函数

import { h, render } from 'vue'
import confirmComponent from './index.vue'
/**
 *
 * @param {*} title 标题
 * @param {*} content 文本
 * @param {*} cancelText 取消按钮文本
 * @param {*} confirmText 确定按钮文本
 * @returns
 */
export const confirm = (
  title,
  content,
  cancelText = '取消',
  confirmText = '确定'
) => {
  return new Promise((resolve, reject) => {
    // 允许只传递 content
    if (title && !content) {
      content = title
      title = ''
    }

    // 关闭弹层事件
    const close = () => {
      render(null, document.body)
    }

    // 取消按钮事件
    const cancelHandler = () => {
      // reject(new Error('取消按钮点击'))
    }

    // 确定按钮事件
    const confirmHandler = () => {
      resolve()
    }

    // 1. vnode
    const vnode = h(confirmComponent, {
      title,
      content,
      cancelText,
      confirmText,
      confirmHandler,
      cancelHandler,
      close
    })
    // 2. render
    render(vnode, document.body)
  })
}

message 组件

image.png

能力分析

我们希望组件的以下内容,可以通过传入参数实现定制

  1. 文案内容
  2. type类型:success,warn,fail
  3. 展示的时长
  4. 关闭时的回调事件
const props = defineProps({
  /**
   * message 的消息类型
   */
  type: {
    type: String,
    required: true,
    validator(val) {
      const result = typeEnum.includes(val)
      if (!result) {
        throw new Error(`你的 type 必须是 ${typeEnum.join('、')} 中的一个`)
      }
      return result
    }
  },
  /**
   * 描述文本
   */
  content: {
    type: String,
    required: true
  },
  /**
   * 展示时长
   */
  duration: {
    type: Number
  },
  /**
   * 关闭时的回调
   */
  destroy: {
    type: Function
  }
})

代码实现

<template>
    <transition name="down" @after-leave="destroy">
        <div v-show="isVisable"
            class="min-w-[420px] fixed top-[20px] left-[50%] translate-x-[-50%] z-50 flex items-center px-3 py-1.5 rounded-sm border cursor-pointer"
            :class="styles[type].containerClass">
            <m-svg-icon :name="styles[type].icon" :fillClass="styles[type].fillClass"
                class="h-1.5 w-1.5 mr-1.5"></m-svg-icon>
            <span class="text-sm" :class="styles[type].textClass">
                {{ content }}
            </span>
        </div>
    </transition>
</template>
<script>
const SUCCESS = 'success'
const WARN = 'WARN'
const ERROR = 'ERROR'
const typeEnum = [SUCCESS, WARN, ERROR]
</script>
<script setup>
import { ref, onMounted } from 'vue'
import mSvgIcon from '../svg-icon/index.vue'

const props = defineProps({
    // ....
})

// 样式表数据
const styles = {
    // 警告
    warn: {
        icon: 'warn',
        fillClass: 'fill-warn-300',
        textClass: 'text-warn-300',
        containerClass:
            'bg-warn-100 border-warn-200  hover:shadow-lg hover:shadow-warn-100'
    },
    // 错误
    error: {
        icon: 'error',
        fillClass: 'fill-error-300',
        textClass: 'text-error-300',
        containerClass:
            'bg-error-100 border-error-200  hover:shadow-lg hover:shadow-error-100'
    },
    // 成功
    success: {
        icon: 'success',
        fillClass: 'fill-success-300',
        textClass: 'text-success-300',
        containerClass:
            'bg-success-100 border-success-200  hover:shadow-lg hover:shadow-success-100'
    }
}

// 控制显示处理
const isVisable = ref(false)
/**
 * 保证动画展示,需要在 mounted 之后进行展示
 */
onMounted(() => {
    isVisable.value = true
    /**
     * 延迟时间关闭
     */
    setTimeout(() => {
        isVisable.value = false
    }, props.duration)
})
</script>
<style lang="scss" scoped>
.down-enter-active,
.down-leave-active {
    transition: all 0.5s;
}

.down-enter-from,
.down-leave-to {
    opacity: 0;
    transform: translate3d(-50%, -100px, 0);
}
</style>

message组件我们仍然希望通过函数的方式来调用,和confirm函数的实现方式一样,这里不再记录。

移动端的navbar组件

image.png

能力分析

对于这样一个navbar,分为左中右三块,我们希望

  1. 左边的图标和点击事件可以定制,有一个默认图标
  2. 中间的标题可以定制,通过slot传入
  3. 右边的图标和点击事件可以定制,有一个默认图标

代码实现

<template>
  <div
    class="w-full h-5 border-b flex items-center z-10 bg-white dark:bg-zinc-800 border-b-zinc-200 dark:border-b-zinc-700"
    :class="[sticky ? 'sticky top-0 left-0' : 'relative']"
  >
    <!-- 左 -->
    <div
      class="h-full w-5 absolute left-0 flex items-center justify-center"
      @click="onClickLeft"
    >
      <slot name="left">
        <m-svg-icon
          name="back"
          class="w-2 h-2"
          fillClass="fill-zinc-900 dark:fill-zinc-200"
        />
      </slot>
    </div>
    <!-- 中 -->
    <div
      class="h-full flex items-center justify-center m-auto font-bold text-base text-zinc-900 dark:text-zinc-200"
    >
      <slot></slot>
    </div>
    <!-- 右 -->
    <div
      class="h-full w-5 absolute right-0 flex items-center justify-center"
      @click="onClickRight"
    >
      <slot name="right" />
    </div>
  </div>
</template>
const router = useRouter()
/**
 * 左侧按钮点击事件
 */
const onClickLeft = () => {
  if (props.clickLeft) {
    props.clickLeft()
    return
  }
  router.back()
}

/**
 * 右侧按钮点击事件
 */
const onClickRight = () => {
  if (props.clickRight) {
    props.clickRight()
  }
}

input组件

image.png

能力分析

我们希望input组件支持

  1. 单行输入
  2. 多行输入
  3. 最大字数限制
  4. input事件,change事件等冒泡出去

所以需要参数为:

  1. type:表示单行还是多行
  2. max:最大字数限制,不传没有限制
  3. modelValue:输入字符双向绑定

代码实现

<template>
    <div class="relative">
        <input v-if="type === TYPE_TEXT"
            class="border-gray-200 dark:border-zinc-600 dark:bg-zinc-800 duration-100 dark:text-zinc-400 border-[1px] outline-0 py-0.5 px-1 text-sm rounded-sm focus:border-blue-400 w-full"
            type="text" v-model="text" :maxlength="max" />
        <textarea v-if="type === TYPE_TEXTAREA" v-model="text" :maxlength="max" rows="5"
            class="border-gray-200 dark:border-zinc-600 dark:bg-zinc-800 duration-100 dark:text-zinc-400 border-[1px] outline-0 py-0.5 px-1 text-sm rounded-sm focus:border-blue-400 w-full"></textarea>
        <span v-if="max" class="absolute right-1 bottom-0.5 text-zinc-400 text-xs"
            :class="{ 'text-red-700': currentNumber === parseInt(max) }">{{ currentNumber }} / {{ max }}</span>
    </div>
</template>
<script>
const TYPE_TEXT = 'text'
const TYPE_TEXTAREA = 'textarea'
</script>
<script setup>
const props = defineProps({
    modelValue: {
        required: true,
        type: String
    },
    type: {
        type: String,
        default: TYPE_TEXT,
        validator(value) {
            const arr = [TYPE_TEXT, TYPE_TEXTAREA]
            const result = arr.includes(value)
            if (!result) {
                throw new Error(`type 的值必须在可选范围内 [${arr.join('、')}]`)
            }
            return result
        }
    },
    max: {
        type: [String, Number]
    }
})

// 事件声明
defineEmits(['update:modelValue'])

// 输入的字符
const text = useVModel(props)

// 输入的字符数
const currentNumber = computed(() => {
    return text.value.length
})
</script>

dialog组件

dialog和comfirm组件非常类似,一共只有两个区别:

  1. dialog组件不需要通过函数调用,只需要通过标签调用
  2. dialog的内容不一定是字符串,还可以是各种标签,comfirm是字符串

能力分析

我们希望dialog组件可以通过props进行以下配置

  1. 控制组件显示和隐藏的开关
  2. 标题
  3. 内容
  4. 取消按钮文本
  5. 确定按钮文本
  6. 取消按钮事件
  7. 确定按钮事件
  8. 关闭弹窗的回调
const props = defineProps({
  // 控制开关
  modelValue: {
    type: Boolean,
    required: true
  },
  // 标题
  title: {
    type: String
  },
  // 取消按钮文本
  cancelText: {
    type: String,
    default: '取消'
  },
  // 确定按钮文本
  confirmText: {
    type: String,
    default: '确定'
  },
  // 取消按钮点击事件
  cancelHandler: {
    type: Function
  },
  // 确定按钮点击事件
  confirmHandler: {
    type: Function
  },
  // 关闭的回调
  close: {
    type: Function
  }
})

代码实现

<template>
    <div>
        <!-- 蒙版 -->
        <transition name="fade">
            <div v-if="isVisable" @click="close" class="w-screen h-screen bg-zinc-900/80 z-40 fixed top-0 left-0"></div>
        </transition>
        <!-- 内容 -->
        <transition name="up">
            <div v-if="isVisable"
                class="max-w-[80%] max-h-[80%] overflow-auto fixed top-[10%] left-[50%] translate-x-[-50%] z-50 px-2 py-1.5 rounded-sm border dark:border-zinc-600 cursor-pointer bg-white dark:bg-zinc-800 xl:min-w-[35%]">
                <!-- 标题 -->
                <div class="text-lg font-bold text-zinc-900 dark:text-zinc-200 mb-2" v-if="title">
                    {{ title }}
                </div>
                <!-- 内容 -->
                <div class="text-base text-zinc-900 dark:text-zinc-200 mb-2">
                    <slot />
                </div>
                <!-- 按钮 -->
                <div class="flex justify-end" v-if="cancelHandler || confirmHandler">
                    <m-button type="info" class="mr-2" @click="onCancelClick">{{
                        cancelText
                    }}</m-button>
                    <m-button type="primary" @click="onConfirmClick">{{
                        confirmText
                    }}</m-button>
                </div>
            </div>
        </transition>
    </div>
</template>
<script setup>
import { useVModel } from '@vueuse/core'
const props = defineProps({
    // todo...
})

defineEmits(['update:modelValue'])

// 控制显示处理
const isVisable = useVModel(props)

/**
 * 取消按钮点击事件
 */
const onCancelClick = () => {
    if (props.cancelHandler) {
        props.cancelHandler()
    }
    close()
}

/**
 * 确定按钮点击事件
 */
const onConfirmClick = () => {
    if (props.confirmHandler) {
        props.confirmHandler()
    }
    close()
}

const close = () => {
    isVisable.value = false
    if (props.close) {
        props.close()
    }
}
</script>