vue3+TS 通用组件封装

923 阅读2分钟

这是我第一次对vue3+ts进行实践,写的比较菜。组件的使用方式是模仿element plus的。内容有点长,挑需要的看就好...

1. Input 输入框

2. CheckBox 复选框

3. RadioGroup 单选框

4. Form和FormItem 表单(主要用于排版)

5. Table和TableColumn 表格

6. Upload 上传

7. Message 提示

8. Icon 图标


源码和示例都在这个仓库中gitee.com/chenxiangzh… 可以直接安装运行查看部分demos:

1. Input

使用

<template>
    <s-input 
        @update="updateForm" 
        name="ip" 
        :value="state.ip"
        :rules="[
           { type: 'required', message: 'ip不能为空' },
           { type: 'ip', message: 'ip格式不对' },
        ]"
    />
</template>


<script lang="ts" setup>
import { reactive } from "vue"
import { SInput } from "@/components"
const state = reactive({
  ip: ""
})
const updateForm = (param) => {
  state[param.name] = param.value
}
</script>

效果

bandicam 2022-10-17 10-33-56-622-16659757630492.gif

封装

types

// 属性值
type InputProp={
  type: string,				// 输入框类型
  name: string,
  value: string|number|boolean,
  placeholder?: string,
  rules?: RuleProp[],			// 校验规则
  inputStyle: Object			// 输入框样式
}

// 校验类型:每个校验类型会对应一个校验函数。
type RuleKeys = "required" | "ip" | "port" | "range"; 
interface IRuleStrategies {
   [index in RuleKeys]: (...params: any[]) => boolean;
   [index: string]: (...params: any[]) => boolean;
}

// 校验对象
interface RuleProp {
   type: RuleKeys;
   message: string;
}

SInput.vue

<template>
    <div class="input-container">
        <input
            :type="type"
            class="input-control"
            @blur="testError"
            :value="value"
            @input="input"
            :class="{ 'is-invalid': inputRef.error }"
            :placeholder="placeholder"
            :style="{...inputStyle }"
        />
        <div v-if="inputRef.error" class="error-text">
            {{ inputRef.message }}
        </div>
    </div>
</template>

<script lang="ts" setup>
import { PropType, reactive } from "vue"
import { RuleProp } from "@types"
import { ruleStrategies } from "@/utils"

const emitFn = defineEmits(["update"])
const props = defineProps({
  type: {
    type: String,
    default: "text"
  },
  name: String,
  value: [String, Number, Boolean],
  placeholder: String,
  rules: Array as PropType<RuleProp[]>,
  inputStyle: Object
})

// 响应式错误信息
const inputRef = reactive({
  error: false,
  message: ""
})

// 检查错误
const testError = () => {
  if (props.rules) {
    const allPassed = props.rules.every((rule: RuleProp) => {
      let passed = true
      inputRef.message = rule.message
      passed = ruleStrategies[rule.type](props.value) // 根据校验类型判断是否通过
      return passed
    })
    inputRef.error = !allPassed
  }
}

// 传回数据
const input = (e: Event) => {
  const targetValue = (e.target as HTMLInputElement).value
  emitFn("update", { name: props.name, value: targetValue })  // 传回一个对象
}
</script>

<style lang="less">
.input-container {
   .input-control{
      padding: 4px 10px;
      border-radius: @smallRadius;
      border: 1px solid @borderColor;
      align-items: center;
      font-size: 1em;
      &:focus{
         border: 1px solid @activeColor;
         outline: 2px solid @mainColor;
      }
   }
   .error-text{
      padding:6px 0 0 1px;
      color:@danger;
      text-align:left;
   }
}
</style>

校验规则(通用)

import {IRuleStrategies} from '@types'

// 判断是否为空
const isRequired=(val:string):boolean=>{
    return val!== ""
}

// 判断是否为整数
const isInteger=(value:any)=>{
    return Number.isInteger(Number(value))
}

// 判断是否为ip
const isIp=(ip:string):boolean=>{
    var rep = /^(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$/;
    return rep.test(ip)
}

// 判断端口是否合法
const isPort=(port:number):boolean=>{
    if(!isInteger(port)||65535<port||port<1){
        return false;
    }  
    return true;
}

// 判断整数范围
const isInRange:number,max:number=20,min:number=-10):boolean=>{
    if(!isInteger(value)||value>max||min<-10){
        return false
    }
    return true
}

// 导出校验策略
export const ruleStrategies:IRuleStrategies={
    "required":isRequired,
    "ip":isIp,
    "port":isPort,
    "range":isInRange
}

2. CheckBox

使用

<s-checkbox
    :checked="state.remember"
    name="remember"
    @update="updateForm"
    label="记住密码"
/>

<script lang="ts" setup>
import { SCheckbox } from "@/components"
// ...

const updateForm = (param:BaseForm) => {
  state[param.name] = param.value
}
</script>

封装

<template>
    <div class="s-checkbox">
        <input type="checkbox" id="checkbox" :checked="props.checked" @input="update">
        <label for="checkbox">{{ props.label }}</label>
    </div>
</template>

<script lang="ts" setup>
const emit = defineEmits(["update"])

// 支持传入 checked name label
const props = defineProps({
  checked: {
    type: Boolean,
    default: false
  },
  name: String,
  label: {
    type: String,
    default: ""
  }
})

const update = (e:Event) => {
  const targetChecked = (e.target as HTMLInputElement).checked
  emit("update", { name: props.name, value: targetChecked })  // 传出名称与布尔值
}
</script>

3. RadioGroup

支持横向分布与竖直分布,通过layout改变

使用

<template>
 <s-radio-group
     name="status"
     :options="statusOptions"
     layout="column"
     :value="state.status"
     @update="updateForm"
 />
</template>

<script lang="ts" setup>
import { SRadioGroup } from "@/components"
// ...
const statusOptions = [
    { label: "启用", value: 1 },
    { label: "禁用", value: 0 }
]
const updateForm = (params: BaseForm) => {
    state[params.name]: Number(params.value)
}
</script>

封装

<template>
    <div :class="['s-radio-group',{column:props.layout==='column'},{row:props.layout==='row'}]">
        <div class="s-radio" v-for="item in props.options" :key="item.key" @click="onChange">
            <input
                type="radio"
                :id="item.key"
                :value="item.value"
                :checked="props.value === item.value"
            />
            <label :for="item.key" :data-value="item.value">{{ item.label }}</label>
        </div>
    </div>
</template>

<script lang="ts" setup>
// 单选框
type Radio = {
   label: string;
   value: string | number;
   key?: string;
};
type RadioOptions = Array<Radio>;


import { PropType } from "vue"
const props = defineProps({
  options: Array as PropType<RadioOptions>, // 选项
  value: [String, Number],		// 值
  name: String,				// 名称
  layout: {			        // 布局方向
    type: String,
    default: "row"
  }
})

const emit = defineEmits(["update"])

// 改变值
const onChange = (e: Event) => {
  const target = (e.target as HTMLInputElement)
  if (target.tagName === "LABEL") {
    emit("update", { name: props.name, value: target.dataset.value })
  } else if (target.tagName === "INPUT") {
    emit("update", { name: props.name, value: target.value })
  }
}
</script>

<style lang="less" scoped>
    .column{
        display: flex;
        flex-direction: column;
        margin-bottom: 10px;
    }
    .row{
      display: grid;
      grid-template-columns: 1fr 1fr;
    }
    .s-radio-group{
        .s-radio {
            input,label{
                cursor: pointer;
            }
            line-height: @listHeight;
            height: @listHeight;
            margin-right: 10px;
            white-space: nowrap;
        }
    }
</style>

4. Form和FormItem

这个组件主要用于表单的布局

使用

<s-form width="400px" :labelCol="2">
  <s-form-item label="IP地址">
      <s-input
          :rules="[
             { type: 'required', message: 'ip不能为空' },
             { type: 'ip', message: 'ip格式不对' },
          ]"
          :value="form.ip"
          @update="updateForm"
          name="ip"
      />
  </s-form-item>
</s-form>

效果见Input的效果

封装

SForm.vue

<script lang="ts">
import { h } from "vue"

export default {
  name: "SForm",
  props: {
    width: String,      // 表单宽度
    labelCol: Number	// label所占宽度的比例
  },
  setup (props, context) {
    if (!context.slots || !context.slots.default) return null
      
    // 将form的属性传给formitem
    const slots = context.slots.default().map(slot => ({
      ...slot,
      props: {
        ...props,	// 父组件form的属性
        ...slot.props	// 子组件formitem的属性,如果有,会覆盖父组件form的属性(以增强子组件样式的优先级
      }
    }))
    return () => h("div", {
      className: "s-form"
    }, slots)
  }
}

</script>
<style lang="less">
    .s-form{
        .s-form-item{
            margin-top: 10px
        }
    }
</style>

SFormItem.vue

<template>
    <div class="s-form-item" :style="{width}">
        <div class="label" :style="{width:labelWidth}">{{label?`${label}:`:' '}}</div>
        <slot></slot>
    </div>
</template>

<script lang="ts">
export default {
  name: "s-form-item",
  props: {
    label: {			// label名称
      type: String,
      default: ""
    },
    width: {			// 占表格宽度的百分比
      type: String,
      default: "100%"
    },
    labelCol: {			// label所占宽度
      type: Number,
      default: 1
    }
  },
    
  setup (props: {labelCol:number, width:string}) {
    const persents = ["10%", "20%", "30%", "40%", "50%", "60%", "80%", "90%", "100%"]
    const labelWidth = persents[props.labelCol]
    return {
      labelWidth,
      ...props
    }
  }
}

</script>
<style lang="less" scoped>
  .s-form-item{
    display: flex;
    align-items: baseline;
    .label{
      text-align: right;
    }
  }
</style>

5. Table和TableColumn

  • 支持横向分布和纵向分布
  • 可自定义内容 (这是我花时间最久的组件)

使用

<s-table :dataSource="dataSource" layout="horizon" :header-style="{width:'180px'}">
  <s-table-column prop="name" label="/"/>
  <s-table-column prop="status" label="采集通道状态">
    <template #default="defaultProps">
      <span v-if="defaultProps.value">已启用</span>
      <span v-else class="danger-text">异常</span>
    </template>
  </s-table-column>
  <s-table-column prop="mockInput" label="模拟音频输入">
    <template #default="defaultProps">
      {{MOCK_INPUT[defaultProps.value]}}
    </template>
  </s-table-column>
  <s-table-column prop="audioHertz" label="音频采样率">
    <template #default="defaultProps">
      {{HERTZ[defaultProps.value]}}
    </template>
  </s-table-column>
  <s-table-column prop="encodeRate" label="音频编码码率">
    <template #default="defaultProps">
      {{RATE[defaultProps.value]}}
    </template>
  </s-table-column>
  <s-table-column prop="encodeFormat" label="音频编码格式"/>
  <s-table-column prop="vol" label="音量"/>
  <s-table-column prop="outputIp" label="输出地址"/>
  <s-table-column prop="outputPort" label="输出端口"/>
</s-table>

<script lang="ts" setup>
const dataSource=[
  {
    "id": 1,
    "name": "音频采集通道1",
    "status": 0,
    "mockInput": 0,
    "audioHertz": 0,
    "encodeRate": 1,
    "encodeFormat": "MP2",
    "outputIp": "192.168.42.10",
    "outputPort": 8000,
    "vol":0
  },
  {
      "id": 2,
      "name": "音频采集通道2",
      "status": 1,
      "mockInput": 1,
      "audioHertz": 1,
      "encodeRate": 2,
      "encodeFormat": "MP3",
      "outputIp": "192.168.42.108",
      "outputPort": 89,
      "vol":0
  },
  {
      "id": 3,
      "name": "音频采集通道3",
      "status": 1,
      "mockInput": 0,
      "audioHertz": 1,
      "encodeRate": 2,
      "encodeFormat": "MP3",
      "outputIp": "192.168.8.10",
      "outputPort": 8080,
      "vol":0
  },
  {
      "id": 4,
      "name": "音频采集通道4",
      "status": 1,
      "mockInput": 1,
      "audioHertz": 0,
      "encodeRate": 0,
      "encodeFormat": "MP2",
      "outputIp": "192.166.42.10",
      "outputPort": 100,
      "vol":0
  }
]
</script>

效果

横向效果

竖向效果

封装

types

// Table 方向
type TableLayout = "horizon" | "column";

// Table 支持的头部样式
interface HeaderStyle {
   width?: string;
   height?: string;
   textAlign?: TableAlign;
}

// Table 文本方向
type TableAlign = "left" | "center" | "right";

// 真实运用到dom的头部样式
interface RealHeaderStyle extends HeaderStyle {
   minWidth?: string;
   minHeight?: string;
   lineHeight?: string;
   flex?: number;
   display?: string;
   alignItems?: string;
   justifyContent?: string;
   [index: string]: string | number | undefined;
}

// 每一条数据的格式(根据需要自定义)
type TableCell = {
   id: number | string;
   [index: string]: any;
};

STable.vue

<script lang="ts">
import { h, PropType, reactive, watchEffect } from "vue"
import { TableCell, HeaderStyle, TableAlign } from "@types"
export default {
  name: "STable",
  props: {
    // 数据源
    dataSource: {
      type: Array as PropType<TableCell[]>,
      default: () => []
    },  
    // 头部样式
    headerStyle: Object as PropType<HeaderStyle>,
    // 表格排列方向
    layout: {
      type: String,
      default: "horizon"
    },
    // 文本布局
    align: {
      type: String as PropType<TableAlign>,
      default: "left"
    }
  },

  setup (props, context) {
    if (!context.slots || !context.slots.default) return null
    let slots = reactive<any[]>([])

    watchEffect(() => {
      if (!context.slots || !context.slots.default) return null
      slots = context.slots.default().map((slot) => ({
        ...slot,
        props: {
          ...props,
          ...slot.props
        }
      }))
    })

    // 根据不同的layout渲染不同的样式
    if (props.layout === "column") {
      return () =>
        h(
          "div",
          {
            className: "s-table table-layout-column"
          },
          slots
        )
    } else {
      return () =>
        h(
          "div",
          {
            className: "s-table-wrap"
          },
          [h("div", { className: "s-table table-layout-horizon" }, slots)]
        )
    }
  }
}
</script>

<style lang="less">
    // 横向时样式,需要包裹一下
   .s-table-wrap {
      overflow: auto;
      .s-table {
         border-radius: @bigRadius;
         &.table-layout-horizon {
            display: grid;
            .table-columns {
               display: flex;
               .table-cell {
                  flex: 1;
               }
               // 从第二行开始
               &:nth-child(n + 2) {
                  .table-title,
                  .table-cell {
                     border-right: 1px dotted @borderColor;
                     border-bottom: 1px dotted @borderColor;
                  }
                  .table-title {
                     background-color: @default;
                  }
               }
               &:first-child {
                  .table-title {
                     color: @white;
                  }
                  .table-cell {
                     background-color: @default;
                     border-bottom: 1px dotted @borderColor;
                     &:nth-child(n + 3) {
                        border-left: 1px dotted @borderColor;
                     }
                  }
               }
            }
         }
      }
   }
    
    // 竖向时样式
   .s-table {
      overflow-x: auto;
      border-radius: @bigRadius;
      &.table-layout-column {
         display: flex;
         .table-columns {
            display: flex;
            flex-direction: column;
            flex-basis: 100px;
            &:nth-child(n + 3) {
               .table-cell {
                  border-top: 1px dotted @borderColor;
               }
            }
            .table-title {
               white-space: nowrap;
               border-bottom: 1px dotted @borderColor;
               background-color: @default;
               position: relative;
            }
            .table-cell {
               flex: 1;
            }
         }
      }
   }
</style>

STableColumn.vue

<template>
    <ul class="table-columns" :key="prop">
        <li :class="['table-title',textAlign]" :key="prop" :style="{...style}">{{label}}</li>
        <li :class="['table-cell',textAlign]" v-for="data in dataSource" :key="data.id">
          <slot name="default" :data="data" :value="data[prop]">
            {{data[prop]}} <!-- 后备数据 -->
          </slot>
        </li>
    </ul>
</template>

<script lang='ts'>
import { PropType, ref } from "vue"
import { TableCell, HeaderStyle, RealHeaderStyle, TableLayout, TableAlign } from "@types"

export default {
  props: {
    // Table传过来的数据源
    dataSource: {
      type: Array as PropType<TableCell[]>,
      default: () => []
    },
    // 列对应的字段
    prop: {
      type: String,
      default: ""
    },
    // 列名
    label: {
      type: String,
      require: true
    },
    // Table传过来的头部样式
    headerStyle: Object as PropType<HeaderStyle>, 
    layout: {
      type: String as PropType<TableLayout>,
      default: "horizon"
    },
    // 列宽(优先级高
    width: {
      type: String,
      default: ""
    }, 
    // 列的文字布局(优先级高
    align: {
      type: String as PropType<TableAlign>,
      default: "left"
    }
  },

  setup (props, context) {
    const style = ref<RealHeaderStyle>({})
    const textAlign = ref(props.align)
    const colWidth = ref(props.width)
    
    // 头部的默认样式与自定义样式
    const HS = props.headerStyle || undefined
    // 【头左体右】表格样式
    if (props.layout === "horizon") {
      style.value.minWidth = "180px" // 默认样式
      if (HS) {
        style.value = {
          ...style.value,
          ...HS,					// 如果table传了样式,覆盖默认样式
        }
      }
    } else {
    // 【头上体下】表格样式(正常方向表格)
      if (HS) {
        style.value = { ...HS }
        if (HS.width) {
          style.value.minWidth = HS.width // 避免头部的宽度小于内容宽度
        }
        if (HS.height) {
          style.value.minHeight = HS.height
          style.value.lineHeight = HS.height
        }
      }
      style.value.minWidth = colWidth.value // 如果列传了宽度,覆盖表格传的样式
    }

    return {
      style,
      textAlign
    }
  }
}

</script>

<style lang="less">

.table-cell,.table-title{
  padding: @itemSpace;
}
.table-columns{
    .center{
      text-align: center;
    }
    .left{
      text-align: left;
    }
    .right{
      text-align: right;
    }
}
</style>

6. Upload

使用

<s-upload @onSuccess="onSucessUpload" :showFiles="true" action=" ">
	<s-button :disabled="version.isUpdate">浏览</s-button>
</s-upload>

效果

封装

types

// 文件上传限制
type Limit={
    size?:number, 		// 文件大小  单位M
    maxFiles?:number,	 // 文件数量
    [index:string]:string|number|undefined
}

// 文件状态
enum FILE_STATUS{
    EMPTY=0,
    SUCCESS=1,
    ERROR=2,
    UPLOADING=3
}
    
// 组件状态
type State={
    fileData:any[]|object,// 当前文件
    fileStatus:FILE_STATUS, // 文件上传状态
    fileList:FileList|[], // 文件列表
    fileIndex:number  	// 文件列表的处理索引
}

// Upload属性
type UploadProp={
    action?: string, // 上传链接
    initFile?: Array<any> | Object,// 初始文件
    accept?: string | Array<string>,// 允许上传的格式
    limit?: Limit, // 上传限制
    multiple?:boolean,// 是否允许多选,
    beforeUpload: (files:FileList)=>boolean,// 上传前处理函数
    showFiles: boolean, // 是否显示文件信息
    help: string// 辅助信息
}
<template>
  <div class="upload-container">
    <!-- 上传触件 -->
    <div class="trigger-container" @click="onUpload">
      <input
        class="hidden"
        ref="fileUploader"
        type="file"
        :multiple="multiple"
        :accept="acceptType"
        @change="fileChange"
      />
      <slot></slot>
    </div>
      
    <!-- 提示信息 -->
    <div v-if="help" class="file-help">
      {{help}}
    </div>
      
    <!-- 文件信息 -->
    <ul class="files-container" v-if="showFiles">
      <li v-for="file in fileList" :key="file.name" class="sspace-vertical">
        <s-icon icon="icon-file" type="symbol"/>
        <span class="sspace-horizon">{{file.name}}</span>
      </li>
    </ul>
  </div>
</template>

<script lang='ts'>
import $axios from "@/request"
import { PropType, computed, ref, watch, reactive, toRefs } from "vue"
import { Message, SIcon } from "@/components"
import { Limit, FILE_STATUS, State} from "@types"

export default {
  name: "s-upload",
  components: { SIcon },
  props: {
    // 上传连接
    action: String,
    // 初始文件
    initFile: {
      type: [Array, Object],
      default: null
    },
    // 允许上传的格式
    accept: {
      type: [String, Array],
      default: "image/*"
    },
    // 上传限制
    limit: Object as PropType<Limit>,
    // 是否允许多选
    multiple: {
      type: Boolean,
      default: false
    },
    // 上传前处理函数
    beforeUpload: Function as PropType<(files:FileList)=>boolean>,
    // 是否显示文件信息
    showFiles: {
      type: Boolean,
      default: false
    },
    // 辅助信息
    help: String
  },

  emits: ["onSuccess", "onError"],

  setup (props, context) {
    const fileUploader = ref<null | HTMLInputElement>(null)

    const acceptType = computed(() => {
      if (typeof props.accept !== "string") {
        if (Array.isArray(props.accept)) {
          return props.accept.join()
        } else {
          console.error("accept接收字符串或数组,请输入正确的格式")
        }
      }
      return props.accept
    })

    const state = reactive<State>({
      fileData: props.initFile,
      fileStatus: FILE_STATUS.ERROR,
      fileList: [],
      fileIndex: 0
    })

    // 监听是否有初始文件
    watch(() => props.initFile, (val) => {
      if (val) {
        state.fileStatus = FILE_STATUS.SUCCESS
        state.fileData = val
      }
    })

    const onUpload = (e:Event) => {
      if (fileUploader.value) {
        fileUploader.value.click()
      }
    }

    // 自定义验证 处理beforeUploadu
    const customCheck = async (files:FileList) => {
      return new Promise((resolve, reject) => {
        if (props.beforeUpload) {
          const result = props.beforeUpload(files)
          if (typeof result !== "boolean") {
            reject(new Error("beforeUploadu应该返回一个布尔值"))
          }
          resolve(result)
        } else {
          resolve(true)
        }
      })
    }

    // 文件大小验证
    const sizeCheck = (files:FileList) => {
      return new Promise((resolve, reject) => {
        const { size } = props.limit
        if (size) {
          let index = 0
          while (index < files.length) {
            const file = files[index]
            const fileSize = file.size / 1024
            if (fileSize > size) {
              const msg = `${file.name}文件大小超出${size}K,请重新调整!`
              Message.error(msg)
              reject(new Error(msg))
            }
            index++
          }
          resolve(true)
        }
        resolve(true)
      })
    }

    // 文件数量验证
    const lengthCheck = (files:FileList) => {
      return new Promise((resolve, reject) => {
        const { maxFiles } = props.limit
        if (maxFiles) {
          console.log(files.length, maxFiles)
          if (files.length > maxFiles) {
            const msg = `文件数量不得超过${maxFiles}个`
            Message.error(msg)
            reject(new Error(msg))
          }
          resolve(true)
        }
        resolve(true)
      })
    }

    
    // 处理上传文件
    const fileChange = async (e:Event) => {
      const target = e.target as HTMLInputElement
      const files = target.files
      if (files && file.length) {
        // 上传前验证
        await customCheck(files)
        if (props.limit) {
          await sizeCheck(files)
          await lengthCheck(files)
        }

        // 本地 不上传到服务器时,直接传回
        if (!props.action) {
          context.emit("onSuccess", files)
          state.fileList = files
          state.fileStatus = FILE_STATUS.SUCCESS
        } else {
          state.fileStatus = FILE_STATUS.UPLOADING
          state.fileList = files
          state.fileIndex = 0
          uploadFile(state.fileList[state.fileIndex])
        }
      }
    }

    // 上传文件
    const uploadFile = async (file:File) => {
      try {
        const fd = new FormData()
        fd.append("file", file)
         const data = await $axios.upload(props.action, fd)
           if (data) {
           await isFinish()
         } else {
           throw new Error(`${file.name}在上传过程中发生错误,上传中止`)
         }
      } catch (err) {
        state.fileStatus = FILE_STATUS.ERROR
        state.fileList = []
        context.emit("onError",`${file.name}在上传过程中发生错误,上传中止`)
      } finally {
        state.fileIndex = 0
      }
      state.fileStatus = FILE_STATUS.SUCCESS
      context.emit("onSuccess", state.fileList)
      state.fileList = []
    }

    // 遍历所有文件
    const isFinish = () => {
      return new Promise((resolve, reject) => {
        // 如果有多个文件
        if (props.multiple && state.fileList.length > 1) {
          // 判断当前文件索引和文件列表长度
          if (state.fileIndex < state.fileList.length - 1) {
            state.fileIndex++
            uploadFile(state.fileList[state.fileIndex])
          } else {
            resolve(FILE_STATUS.SUCCESS)
          }
        } else {
          resolve(FILE_STATUS.SUCCESS)
        }
      })
    }

    return {
      acceptType,
      fileChange,
      onUpload,
      fileUploader,
      ...toRefs(state),
      ...toRefs(props)
    }
  }
}
</script>

<style scoped lang="less">
.upload-container{
  display: flex;
  flex-direction: column;
  .file-help{
    color:@help;
    margin: 10px 0;
    font-size: 0.85em;
  }
  .files-container{
    cursor: default;
  }
}

</style>

7. Message

使用

import { Message } from '@/components'
Message.success("Login successful!")

效果

封装

首先定义一些常量

// 停留时间
const MESSAGE_TIMEOUT: number = 3000; 
// message样式
const MESSAGE_STYLE: IMessageStyle = {
   warn: {
      icon: "icon-warn-fill",
      color: "#E6A23C",
      backgroundColor: "#fff7e6",
      borderColor: "#ffe7ba",
   },
   error: {
      icon: "icon-error-fill",
      color: "#F56C6C",
      backgroundColor: "#fff1f0",
      borderColor: "#ffccc7",
   },
   success: {
      icon: "icon-success-fill",
      color: "#67C23A",
      backgroundColor: "#f6ffed",
      borderColor: "#d9f7be",
   },
   info: {
      icon: "icon-info-fill",
      color: "#40a9ff",
      backgroundColor: "#e6f7ff",
      borderColor: "#bae7ff",
   },
};

SMessage.vue

<template>
    <transition name="fade">
        <div class="s-message" :style="MESSAGE_STYLE[props.type]" v-if="isShow">
            <s-icon :icon="MESSAGE_STYLE[props.type].icon" />
            <span class="text">{{ props.text }}</span>
        </div>
    </transition >
</template>

<script lang="ts" setup>
import { MESSAGE_TIMEOUT, MESSAGE_STYLE } from "@/utils"
import { SIcon } from "@/components"
import { ref, onMounted } from "vue"
const props = defineProps({
  text: {
    type: String,
    default: ""
  },
  type: {
    type: String,
    default: "warn" // warn 警告  error 错误  success 成功
  },
  timeout: {
    type: Number,
    default: MESSAGE_TIMEOUT
  }
})
const isShow = ref<boolean>(false)
onMounted(() => {
  isShow.value = true
  setTimeout(() => {
    isShow.value = false
  }, props.timeout)
})
</script>

<style scoped lang="less">
.fade-enter-active{
  animation: fade .5s;
}
.fade-leave-active {
  animation: fade .5s reverse;
}

// /* 定义帧动画 */
@keyframes fade {
  0% {
    opacity: 0;
    transform: translateY(-50px);
  }

  100% {
    opacity: 1;
  }
}

.s-message {
    min-width: 300px;
    max-width: 350px;
    padding: 12px @itemSpace;
    position: fixed;
    z-index: 9999;
    left: 50%;
    margin-left: -150px;
    top: 25px;
    border-radius: 4px;
    .text {
        vertical-align: middle;
    }
}
</style>

SMessage\index.ts

import { MESSAGE_TIMEOUT } from "@/utils" 
import { createVNode, render } from 'vue'
import SMessage from './SMessage.vue'

const div = document.createElement('div')
// 添加到body上
document.body.appendChild(div)

// 定时器标识
let timer: any = null

// 渲染虚拟dom
const renderMessage=(vnode:any)=>{
    render(vnode, div)
    clearTimeout(timer)
    timer = setTimeout(() => {
        render(null, div)
    }, MESSAGE_TIMEOUT)
}

export default {
    error:(msg:string)=>{
        const vnode = createVNode(SMessage, { type:'error', text:msg })
        renderMessage(vnode)
    },
    warn:(msg:string)=>{
        const vnode = createVNode(SMessage, { type:'warn', text:msg })
        renderMessage(vnode)
    },
    success:(msg:string)=>{
        const vnode = createVNode(SMessage, { type:'success', text:msg })
        renderMessage(vnode)
    },
    info:(msg:string)=>{
        const vnode = createVNode(SMessage, { type:'info', text:msg })
        renderMessage(vnode)
    },
}

8. Icon

使用

<s-icon icon="icon-file" type="symbol"/>

封装

这里使用的icon均自于www.iconfont.cn/,icon组件支持的写…

  • Font class
  • Symbol

选择好icon后,将代码粘贴进去

同时在入口文件导入

SIcon.vue

<template>
    <i :class="[props.icon, 'iconfont']" v-if="type==='font-class'"></i>
    <svg class="icon" aria-hidden="true" v-else>
        <use :xlink:href="`#${props.icon}`"></use>
    </svg>
</template>

<script lang="ts" setup>
const props = defineProps({
  icon: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: "font-class"
  }
})
</script>

<style lang="less" scoped>
i {
    margin-right: 4px;
    vertical-align: middle;
}
</style>