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";