vue3+TS 通用组件封装

这是我第一次对vue3+ts进行实践,写的可能比较菜,见谅。组件的使用方式是模仿element plus的。内容有点长,会持续更新。

源码:https://gitee.com/chenxiangzhi/components

1. Input 输入框
2. Checkbox、CheckeboxGroup
3. RadioGroup 单选框
4. Form和FormItem 表单(主要用于排版,有bug,对立面的FormItem使用v-if不生效,有空在研究下)
5. Table和TableColumn 表格(--弃了不实用,后面有时间再改)
6. Upload 上传
7. Message 提示
8. Icon 图标
9. Modal 弹框
10. v-loading加载指令
11. confirm弹框
12. progress进度条(支持类型:普通加载loading、含进度条step、错误error)
12. useLoad进度条状态管理
部分公共样式

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>

效果

封装

types

// 校验类型
export type RuleKeys = "required" | "ip" | "port" | "vol" | "mac";
//  校验对象
export interface RuleProp {
   type: RuleKeys;
   message: string;
}
// 校验列表
export interface IRulesProp {
   [index: string]: Array<RuleProp>;
}
// 校验策略
export interface IRuleStrategies {
   [index in RuleKeys]: (...params: any[]) => boolean;
   [index: string]: (...params: any[]) => boolean;
}

SInput.vue

<template>
   <div class="input-container">
       <input
           :disabled="disabled"
           :type="type"
           class="input-control"
           @blur="testError"
           :value="props.value"
           @input="input"
           @focus="emitFn('focus')"
           :class="{ 'is-invalid': inputRef.error }"
           :placeholder="placeholder"
           :style="{...inputStyle }"
       />
       <div v-if="inputRef.error" class="error-text" :style="{...inputStyle }">
           {{ 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", "blur", "focus"])

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

// 响应式错误信息
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)
         console.log("allPassed", props.value, ruleStrategies[rule.type])
         return passed
      })
      inputRef.error = !allPassed
   }
   emitFn("blur")
}

// 传回数据
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="checked" @input="update">
        <label for="checkbox">{{ label }}</label>
    </div>
</template>

<script lang="ts" setup>
// 属性
interface Props{
   checked:boolean,
   name?:string,
   label?:string
}
const props = withDefaults(defineProps<Props>(), {
   checked: false
})

// 方法
const emit = defineEmits(["update"])

const update = (e:Event) => {
   const targetChecked = (e.target as HTMLInputElement).checked
   emit("update", { name: props.name, value: targetChecked })
}
</script>

Checkbox-Group

使用

<template>
	<s-checkbox-group
	   name="selectedKeys"
	   :value="state.selectedKeys"
	   :options="[{label:'1',value:1},{label:'2',value:2}]"
	   @update="changeOptions"
	/>
</template>


<script lang="ts" setup>
import { SCheckboxGroup } from "@/components"
import { reactive } from "vue"

const state = reactive({
  selectedKeys: []
})

// 返回选中的value数组和选项数组
const changeOptions = (keys, options) => {
  console.log(keys, options)
}

</script>

Types

// 单个选项
type CheckboxOption={
    label: string,	// 显示的label
    value: string|number, // 对应的值
    key?:string, 
    checked?:boolean // 是否选中
}

// 组件属性
type CheckboxGroupProp={
    value:Array<string|number>, // 选中的值数组
    name:string, // 名称
    options:CheckboxOption[] // 可选列表
}

封装

<template>
    <div class="s-checkbox-group">
        <div class="s-checkbox-group__option" v-for="item in state.options" :key="item.key">
            <input type="checkbox" :id="item.key" :checked="item.checked" @input="(e)=>{update(e,item)}">
            <label :for="item.key">{{ item.label }}</label>
        </div>
    </div>
</template>

<script lang="ts" setup>
import { computed, PropType, reactive } from "vue"
import { CheckboxOption} from "@types"
const emit = defineEmits(["update"])

// 组件属性
const props = defineProps({
  value: {
    type: Array,
    default: () => []
  },
  name: String,
  options: {
    type: Array as PropType<CheckboxOption[]>,
    default: () => []
  }
})

// 初始化选项:加入key属性和checked属性
function initOptions () {
  return props.options.length
    ? props.options.map(op => {
      if (!op.key) {
        op.key = op.value.toString()
      }
      if (props.value.length) {
        op.checked = !!props.value.includes(op.value)
      } else {
        op.checked = false
      }
      return op
    })
    : []
}

// 响应式状态
const state = reactive({
  options: initOptions()
})

// 选中的选项
const selectedOptions = computed(() => state.options.filter(op => op.checked))

// 更新与传递
const update = (e:Event, item:CheckboxOption) => {
  const targetChecked = (e.target as HTMLInputElement).checked
  const target = state.options.find(op => op.key === item.key)
  if (target) {
    target.checked = targetChecked
    emit("update", selectedOptions.value.map(op => op.value), selectedOptions)
  }
}
</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',props.layout]">
        <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>
import { RadioOptions } from "@types"
interface Props {
    options: RadioOptions;
    value: string | number;
    name: string;
    layout?: string;
}
const props = withDefaults(defineProps<Props>(), {
   layout: "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>

    .s-radio-group{
        &.column{
            display: flex;
            flex-direction: column;
        }

        &.row{
            display: flex;
        }
        .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; 

SMessage.vue

<template>
    <transition name="fade">
        <div :class="['s-message',type]" v-if="isShow">
            <!--可参考下面的icon组件-->
            <s-icon :type="icon" />
            <span class="text">{{ props.text }}</span>
        </div>
    </transition >
</template>

<script lang="ts" setup>
import { MESSAGE_TIMEOUT } from "@/utils"
import { SIcon } from "@/components"
import { ref, onMounted, computed } from "vue"
import { IconTypes } from "types"  // 参考下面的icon组件
const props = defineProps({
   text: {
      type: String,
      default: ""
   },
   type: {
      type: String,
      default: "warn" // warn 警告  error 错误  success 成功
   },
   timeout: {
      type: Number,
      default: MESSAGE_TIMEOUT
   }
})

const icon = computed(() => {
   return `icon-${props.type}-fill` as IconTypes
})

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: 8px;
    position: fixed;
    z-index: 9999;
    left: 50%;
    margin-left: -150px;
    top: 25px;
    border-radius: 4px;
    .text {
        vertical-align: middle;
        margin-left: 8px;
    }
    &.warn{
      color: @warn;
      background: #fff7e6;
      border-color: #ffe7ba;
   };
   &.error{
      color: @danger;
      background: #fff1f0;
      border-color: #ffccc7;
   }
   &.success {
      color: @success;
      background: #f6ffed;
      border-color: #d9f7be;
   }
   &.info {
      color: @primary;
      background: #e6f7ff;
      border-color: #bae7ff;
   }
}
</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, timeout: number) => {
   render(null, div);
   render(vnode, div);
   clearTimeout(timer);
   timer = setTimeout(() => {
      render(null, div);
   }, timeout);
};


export default {
   error: (text: string, timeout: number = MESSAGE_TIMEOUT) => {
      const vnode = createVNode(SMessage, { type: "error", text, timeout });
      renderMessage(vnode, timeout);
   },
   warn: (text: string, timeout: number = MESSAGE_TIMEOUT) => {
      const vnode = createVNode(SMessage, { type: "warn", text, timeout });
      renderMessage(vnode, timeout);
   },
   success: (text: string, timeout: number = MESSAGE_TIMEOUT) => {
      const vnode = createVNode(SMessage, { type: "success", text, timeout });
      renderMessage(vnode, timeout);
   },
   info: (text: string, timeout: number = MESSAGE_TIMEOUT) => {
      const vnode = createVNode(SMessage, { type: "info", text, timeout });
      renderMessage(vnode, timeout);
   },
};

8. Icon

使用

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

挑选icon

这里使用的icon均自于https://www.iconfont.cn/,比较常用的写法是Symbol和Font class。该组件采用的是Symbol。(按需使用即可)

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

同时在入口文件导入

封装

types
把找的icon名称列出来,方便后面使用

export type IconTypes = "icon-close" | "icon-user" | "icon-quit" | "icon-file" | "icon-warm-fill" | "icon-success-fill" | "icon-warning-fill" | "icon-info-fill" | "icon-error-fill" | "icon-play-fill" | "icon-stop-fill" | "icon-arrow-up"  | "icon-arrow-down"

SIcon.vue

<template>
    <svg class="icon" aria-hidden="true" @click="emit('click')">
        <use :xlink:href="`#${props.type}`"></use>
    </svg>
</template>

<script lang="ts" setup>
import { IconTypes } from "@types"
interface Props{
    type:IconTypes
}
const emit = defineEmits(["click"])
const props = defineProps<Props>()
</script>

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

.icon {
    width: 1em; height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
 }
</style>

9.Modal

使用

<s-button type="primary" @click="state.isShow=true">打开</s-button>
   <s-modal
      v-model:show="state.isShow"
      title="弹框"
   >
      哈哈哈
      <template #footer>
         <s-button type="primary">应用</s-button>
         <s-button type="default" @click="state.isShow=false">取消</s-button>
      </template>
</s-modal>


const state = reactive({
   isShow: true
})

效果

封装

<template>
  <div class="s-modal" v-if="show">
      <div class="wrap" :style="{width}">
       <!-- 头部 -->
        <div class="header">
            <h3>{{title}}</h3>
            <s-icon
                v-if="showClose"
                type="icon-close"
                class="mfont pointer"
                @click="handleClose"
            />
        </div>
        <!-- 内容 -->
        <div class="content">
            <slot></slot>
        </div>
      </div>
  </div>
</template>

<script lang='ts' setup>
import { SIcon } from "@/components"
interface Props{
   show:boolean,  // 是否显示
   title?:string, // 弹框标题
   width?:string, // 弹框宽度
   showClose?:boolean, // 是否显示关闭图标
   beforeClose?:Function, // 关闭前的函数调用
}
const props = withDefaults(defineProps<Props>(), {
   show: false,
   title: "弹框",
   width: "500px",
   showClose: true
})

const emits = defineEmits(["update:show"])
const handleClose = () => {
   if (props.beforeClose) {
      props.beforeClose()
   } else {
      emits("update:show")
   }
}

</script>

<style scoped lang="less">
.s-modal{
    position: fixed;
    top: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.7);
    right: 0;
    z-index: 999;
    bottom: 0;
    .wrap{
        background: white;
        margin-top: 100px;
        margin: 100px auto;
        border-radius: 2px;
        .header{
            height: 50px;
            line-height: 50px;
            padding: 0 20px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            h3 {
               cursor: default;
               font-size: @middleSize;
            }
        }
        .content{
            padding: 10px 20px 20px 20px;
        }
    }
}
</style>

10. v-loading加载指令

效果

使用

通常会用在数据列表的容器上,以下面的表格为例

 <s-table
	 :dataSource="dataSource"
	 v-loading="dataSource.length === 0"
 />

封装

SLoading/index.vue : 先搞一个加载样式

<template>
   <div class="loading-wrap">
      <div class="loading"></div>
   </div>
</template>

<script lang="ts" setup></script>

<style lang="less" scoped>
   .loading-wrap {
      position: absolute;
      top: 0;
      left: 0;
      background-color: @white;
      right: 0;
      bottom: 0;
      opacity: 0.8;
      .loading {
         position: absolute;
         width: 40px;
         height: 40px;
         top: 50%;
         left: 50%;
         margin-left: -20px;
         margin-top: -20px;
         width: 40px;
         height: 40px;
         border: 2px solid @primary;
         border-top-color: rgba(0, 0, 0, 0.2);
         border-right-color: rgba(0, 0, 0, 0.2);
         border-bottom-color: rgba(0, 0, 0, 0.2);
         border-radius: 100%;
         animation: circle infinite 0.75s linear;
      }

      @keyframes circle {
         0% {
            transform: rotate(0);
         }
         100% {
            transform: rotate(360deg);
         }
      }
   }
</style>

SLoading/index.ts : 配置指令的生命周期

import {createVNode,render} from 'vue'
import Loading from './index.vue' // 引入 loading 组件

const vLoding={
    // dom挂载后执行。这里的el使用了v-loading的那个dom,loading组件将会被挂载在el的下面,作为el的孩子
    mounted: (el, binding)=> {
        if (binding.value) {
            const vm = createVNode(Loading)
            render(vm, el)
        }
    },
    // 指令值变化时执行:比如说空数据变成了有数据
    updated:(el, binding)=>{
        const vm = createVNode(Loading)
        // 数据变化时:
        if (binding.value !== binding.oldValue) {
	         // 只在值变为true的时候渲染loading组件
            binding.value ? render(vm, el) : render(null, el)
        }
  }
}
export default vLoding

main.ts 入口注册

import { Loading } from "@/components"  // 也就是上面的 `SLoading/index.vue`
const app=createApp(App)
app.directive('loading',Loading)  // 接下来就可以使用v-loading了

11.confirm确认框

效果

使用

 import { Confirm } from "@/components"
 
// 文本后面的配置对象为可选,可修改弹框标题和类型
 Confirm("确认恢复出厂设置吗?",{type:'warn',title:'提示'}).then(() => {
      console.log("确定")
   }).catch(() => {
      console.log("取消")
   })

封装

SConform/index.vue :弹框组件

<template>
   <div class="s-confirm" :class="{ fade: fade }">
      <div class="wrapper">
         <div class="header">
            <h3>{{ props.title }}</h3>
            <!--mfont表示中等大小字体 pointer改变鼠标样式,按需修改样式即可 -->
            <s-icon
               type="icon-close"
               @click="props.cancelCallback()"
               class="mfont pointer"
            />
         </div>
         
         <div class="body">
	         <!--bfont表示大字体  `${type}-txt`要根据你允许的type去写样式,我是写在公共样式中的,可滑到文章结尾找到 -->
            <s-icon :type="icon" :class="['bfont',`${type}-txt`]"/>
            <span>{{ props.text }}</span>
         </div>
         
         <div class="footer">
            <s-button @click="props.submitCallback()" type="primary" style="margin-right: 16px;"
               >确认</s-button
            >
            <s-button type="default" @click="props.cancelCallback()">取消</s-button>

         </div>
      </div>
   </div>
</template>

<script lang="ts" setup>
import { SIcon, SButton } from "@/components"
import { onMounted, ref, computed } from "vue"
import { IconTypes } from "@types"

// 属性
const props = defineProps({
   // 弹框标题
   title: {
      type: String,
      default: "提示"
   },
   // 弹框内容
   text: {
      type: String,
      default: ""
   },
   // 弹框类型:warn error info
   type: {
      type: String,
      default: "warn"
   },
   // 成功时的回调
   submitCallback: {
      type: Function
   },
   // 失败时的回调
   cancelCallback: {
      type: Function
   }
})

// 参考icon组件
const icon = computed(() => {
   return `icon-${props.type}-fill` as IconTypes
})

// 控制显示隐藏过渡
const fade = ref(false)
onMounted(() => {
   // 当元素渲染完毕立即过渡的动画不会触发
   setTimeout(() => {
      fade.value = true
   }, 0)
})
</script>
<style scoped lang="less">
   .s-confirm {
      position: fixed;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      z-index: 8888;
      background: rgba(0, 0, 0, 0);
      &.fade {
         transition: all 0.4s;
         background: rgba(0, 0, 0, 0.5);
      }
      .wrapper {
         width: 400px;
         background: @white;
         border-radius: @smallRadius;
         position: absolute;
         top: 50%;
         left: 50%;
         transform: translate(-50%, -60%);
         opacity: 1;
         &.fade {
            transition: all 0.4s;
            transform: translate(-50%, -50%);
            opacity: 1;
         }
         .header {
            height: 50px;
            line-height: 50px;
            padding: 0 20px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            h3 {
               cursor: default;
               font-size: @middleSize;
            }
         }
         .footer {
            padding: 0 20px;
            text-align: right;
            height: 42px;
         }
         .body {
            padding: 20px;
            display: flex;
            align-items: center;
            span {
               margin-left: 10px;
            }
         }
      }
   }
</style>

SConform/index.ts : 挂载组件

import { createVNode, render } from 'vue'
import Confirm from './index.vue'

// 创建一个挂载点
const div = document.createElement('div')
document.body.appendChild(div)

export default (text:string,options?:{title?:string,type?:string}) => {
  return new Promise((resolve, reject) => {

    // 点击确定按钮
    const submitCallback = () => {
      render(null, div)
      resolve()
    }
    // 点击取消按钮
    const cancelCallback = () => {
      render(null, div)
      reject('点击取消')
    }

	// 创建虚拟dom并挂载
    const vnode = createVNode(Confirm, { text, submitCallback, cancelCallback, ...options })
    render(vnode, div)
  })
}

12. progress进度条

效果

使用

// 普通的加载进度条
<s-progress/>  

// 错误进度条
<s-progress type="error"/>

// 含数值的进度条
<s-progress type="step" :val="state.val" :show-text="true"/>

const state=reactive({
	val:0
})
setInterval(()=>{
	state.val+=20
},2000)

上面的效果图还用了Modal组件,只要把进度条组件放在里面即可

封装

SProgress.vue

<template>
   <!--因为进度数值我是显示在右侧的,所以当显示数值时这里会适当加个margin-right留出空间放文本-->
   <div :class="['s-progress', { mr: showText }]">
      <!--进度条容器-->
      <div class="container" :style="{ width }">
         <!--加载条:根据val渲染宽度-->
         <div :class="type" :style="{ width: `${val}%` }"></div>
      </div>
      <!--进度条数值:跟着数值修改偏移位置-->
      <span v-if="showText" class="text" :style="{ left: `${val}%` }">
         {{ `${val}%` }}
      </span>
   </div>
</template>

<script lang="ts" setup>
interface Props {
   type?: "loading" | "step" | "error";  // 类型:loading|一直加载 step|加入数值 error|停止且红色
   width?: string;// 组件宽度
   val?: number;// 进度条数值
   showText?: boolean;// 是否显示数值
}
const props = withDefaults(defineProps<Props>(), {
   type: "loading",
   width: "100%",
   val: 50, // 默认的加载条数值(组件包括容器和加载条)
   showText: false
})

</script>

<style scoped lang="less">
   .s-progress {
      position: relative;
      &.mr {
         margin-right: 42px;
      }
      .container {
         margin: 10px 0;
         overflow: hidden; // 由于加载条是会运动的,所以当加载条超过容器时要隐藏
         background-color: rgba(0, 0, 0, 0.2);
         // 循环运动
         .loading {
            background: @primary;
            height: 2px;
            animation: loading 2s infinite;
         }
         .error {
            background: @danger;
            height: 2px;
         }
         // 当val更新时,宽度也会更新,可加上transition进行样式过渡
         .step {
            background: @primary;
            height: 2px;
            transition: width 2s;
         }
      }
      // 当val更新时,进度条数值位置偏移量(left)也会变化,可加上transition进行样式过渡
      .text {
         position: absolute;
         color: @primary;
         margin-left: 8px;
         top: -4px;
         background-color: @white;
         transition: left 2s;
      }
   }

  // 从左到右循环运动
   @keyframes loading {
      0% {
         transform: translateX(-100%);
      }
      100% {
         transform: translateX(200%);
      }
   }
</style>

12. useLoad进度条状态管理

顺便补充一下,由于原来的项目使用到进度条的地方比较多(配合模态框),因为每个状态都有对应的文本说明和进度条类型,所以我封装了一个钩子函数,有需要的朋友可以看看,建议根据需求适当修改:

封装

src\hooks\useLoad.ts

import {
   computed,
   reactive,
   toRefs
} from "vue";

import { REBOOT_TIME,Timer } from "@/utils"  // 非必需:一个是重启常量 一个是全局定时器。
import { useUser } from "@/hooks"

export const useLoad = () => {
   const { logout } = useUser()

   // 列出用到的状态枚举
   enum STATUS {
      FREE = 0, // 空状态

      UPDATE_UPLOADING = 1, // 上传文件
      UPDATE_START = 2, // 升级
      UPDATE_REBOOTING = 3, // 重启
      UPDATE_ERROR = 4, // 错误

      DEFAULT_START = 5, // 开始恢复默认
      DEFAULT_REBOOTING = 6, // 成功恢复,进入重启
      DEFAULT_ERROR = 7, // 恢复失败

      REBOOT_START = 8, // 开始重启
      REBOOT_ING = 9,  // 正在重启
      REBOOT_END = 10, // 成功重启
      REBOOT_ERROR = 11, // 重启失败

      CONFIG_UPLOADING = 12, // 上传配置文件
      CONFIG_ERROR = 13, // 上传错误
      CONFIG_REBOOTING = 14, // 成功后重启
   }

  // 响应式状态
   const state = reactive({
      loadStatus: STATUS.FREE,  // 状态
      loadVal: 0,                // 数值
   });


   // 每种状态对应的不同样式:说明文字、进度条类型、是否显示弹框的关闭图标
   const statusMap = {
      [STATUS.FREE]: { tip: "提示", progress: "" ,showClose: true },
      [STATUS.UPDATE_UPLOADING]: { tip: "正在上传文件中...请不要断电!", progress: "loading",showClose:false },
      [STATUS.UPDATE_START]: { tip: "正在升级...", progress: "step" ,showClose:false },
      [STATUS.UPDATE_REBOOTING]: { tip: "设备正在重启中...请稍后", progress: "step",showClose:false },
      [STATUS.UPDATE_ERROR]: { tip: "升级出错,请重试!", progress: "error" ,showClose:true},
      [STATUS.DEFAULT_START]:{ tip: "正在恢复出厂设置,请稍等...", progress: "loading" ,showClose:false},
      [STATUS.DEFAULT_REBOOTING]:{tip: "恢复出厂设置成功,系统自动重启中...!", progress: "loading" ,showClose:false},
      [STATUS.DEFAULT_ERROR]:{tip: "恢复出厂设置出错,请重试!",progress:"error" ,showClose:true},
      [STATUS.REBOOT_START]: {tip: "正在处理重启请求...",progress:"loading" ,showClose:false},
      [STATUS.REBOOT_ING]: {tip: "系统重启中...",progress:"loading", showClose:false},
      [STATUS.REBOOT_END]: {tip: "系统重启成功",progress:"", showClose:true},
      [STATUS.REBOOT_ERROR]: {tip: "系统重启失败,请重试",progress:"error",showClose:true},
      [STATUS.CONFIG_UPLOADING]: {tip: "正在上传文件中...请不要断电!",progress:"loading",showClose:false},
      [STATUS.CONFIG_ERROR]: {tip: "导入失败",progress:"error",showClose:true},
      [STATUS.CONFIG_REBOOTING]: {tip: "导入成功,系统自动重启中...!",progress:"loading",showClose:false}
   }

  // 改变状态
   const changeStatus = (value: number) => {
      state.loadStatus = value
   }

   // 重置状态(配合弹框使用)
    const closeLoad = () => {
	   state.loadStatus = STATUS.FREE
	   state.loadVal = 0
    }

   // 改变数值
   const changeLoadVal = (val: number) => {
      state.loadVal = val
   }
   // 根据状态 获取是否显示关闭的布尔值
   const showClose = computed(() => {
      return statusMap[state.loadStatus].showClose;
   })

   // 根据状态 获取进度条类型
   const progressType = computed(() => {
      return statusMap[state.loadStatus].progress;
   })

   // 根据状态 获取说明文字
   const tip = computed(() => {
      return statusMap[state.loadStatus].tip;
   });

   // 控制弹框的显示与关闭 (配合弹框使用) 我这里只要不是空闲就会打开弹框 否则关闭
   const loadShow = computed(() => {
      return state.loadStatus !== STATUS.FREE
   })

   // 【特殊】重启
   const goReboot = () => {
      clearInterval(Timer.refreshSysInfo) // 如果当时有轮询的请求 清理掉
      let timer: any = null
      timer = setTimeout(() => {
         closeLoad()
         logout()
         clearTimeout(timer)
      }, REBOOT_TIME)
   }

   return {
      ...toRefs(state),
      tip,
      STATUS,
      loadShow,
      changeStatus,
      closeLoad,
      changeLoadVal,
      REBOOT_TIME,
      goReboot,
      showClose,
      progressType
   };
};

示例1

那我是怎么使用的呢?下面以导入配置为例,它的状态包含:上传文件-成功时重启/失败时提示

<s-button type="primary" @click="handleImport">导入配置</s-button>
<s-modal
      v-model:show="loadShow"
      :title="tip"
      :before-close="closeLoad"
      :show-close="showClose"
   >
      <s-progress :type="progressType"/>
</s-modal>
import { useLoad } from "@/hooks"
import { importConfig, exportConfig } from "@/request"  // 一些请求
const {
   STATUS,
   tip, // 说明文字
   loadShow, // 弹框显示
   closeLoad, // 【事件】关闭弹框
   changeStatus, // 【事件】改变状态
   goReboot, // 【事件】重启
   showClose, // 是否显示关闭
   progressType // 进度条类型
} = useLoad()


// 导入配置
const handleImport = () => {
   // 假设已经有文件了
   // 直接改变状态
   // 这个时候 弹框的显示loadShow 说明文字tip 进度条类型progressType 都会发生响应变化
   changeStatus(STATUS.CONFIG_UPLOADING)
   importConfig(fb).then(res => {
      if (res) {
         Message.success("导入成功")
         // 导入成功时改变状态
         changeStatus(STATUS.CONFIG_REBOOTING)
         goReboot()
      } else {
         // 导入失败时改变状态
         changeStatus(STATUS.CONFIG_ERROR)
      }
   })
}

示例2

再举个含数值的升级例子,这边稍微复杂点。它包含的状态:上传文件-升级进度-重启

  <s-button type="primary" @click="handleUpgrade">升级</s-button>
  <s-modal
      v-model:show="loadShow"
      :title="tip"
      :before-close="closeLoad"
      :show-close="showClose"
   >
      <s-progress :type="progressType" :val="progressType==='step'?loadVal:50" :show-text="progressType==='step'"/>
   </s-modal>
import { useLoad } from "@/hooks"
import { upgrade, getProgressBarVal } from "@/request"  // 一些相关请求
const {
   STATUS,
   tip,
   loadShow,
   closeLoad,
   loadVal,  // + 进度条数值
   changeStatus,
   goReboot,
   showClose,
   progressType,
   changeLoadVal // + 【事件】改变进度条数值
} = useLoad()

// 升级文件-上传文件
const handleUpgrade = () => {
   // ..假设已经上传了文件
   changeStatus(STATUS.UPDATE_UPLOADING)
   // 调用上传文件接口
   upgrade(fb)
   getProgress() // 紧接着轮询获取进度
}

const getProgress = () => {
   let timer: any = null
   timer = setInterval(() => {
      // 调用获取进度接口,
      // 它会返回一个上传文件进度barval 和 升级进度upgradebarval
      // 上传时用的进度条类型是loading,升级的是step
      getProgressBarVal() 
         .then((data) => {
            if (data) {
	            // 当上传与升级都成功时
               if (data.barval === 100 && data.upgradebarval === 100) {
                  // 改变进度条数值与状态
                  changeLoadVal(100)
                  changeStatus(STATUS.UPDATE_REBOOTING)
                  Message.success("升级成功,请等待重启...")
                  clearInterval(timer)
                  goReboot()
               } else {
                  // 当正在上传
                  if (data.barval < 100) {
                  
                  // 当上传结束
                  } else {
                     // 当正在升级
                     if (data.upgradebarval < 100) {
                        // 改变进度条数值与状态
                        changeStatus(STATUS.UPDATE_START)
                        changeLoadVal(data.upgradebarval)
                     }
                  }
               }
            } else {
               changeStatus(STATUS.UPDATE_ERROR)
               clearInterval(timer)
            }
         })
         .catch(() => {
            changeStatus(STATUS.UPDATE_ERROR)
            Message.error("请求出错,请稍后再试")
            clearInterval(timer)
         })
   }, 2000)
}

部分公共样式

公共变量:src\assets\styles\variables.less

@primary:#1890ff;
@info:#1890ff;
@danger:#F56C6C;
@warn:#E6A23C;
@success:#5CB85C;
@default:#f0f5ff;
@help:#595959;

公共样式:src\assets\styles\index.less

@import "reset.css"; // 网上搜reset css即可找到。重置了原本的标签样式
@import "variables.less";

.danger-txt{
    color:@danger;
}
.error-txt{
    color:@danger;
}
.success-txt{
    color:@success;
}
.warn-txt{
    color: @warn;
}

.pointer{
    cursor: pointer;
}
.not-pointer{
    cursor:not-allowed;
}

main.ts入口导入

import "@/assets/styles/index.less";
posted @ 2022-10-17 15:14  sanhuamao  阅读(8982)  评论(14编辑  收藏  举报