前言
这次用了个vue2.0+vuetify的项目,完事后总结下,顺便吐吐槽
vuetify个人感觉还是可以,不知道为啥国内饿了么当了头牌。下面全是我个人使用的体验和感觉,以及一些组件代码和踩到过的坑。最要命的是,UI设计简直和饿了么风格一模一样。。。
一、Vuetify整体感觉。
1.开发页面不用写css,需要的基本都是全局会有的很人性,用法类似于tailwindcss不晓得这两娃有关系没得。
2.组件的封装度比较高,自定义起来比较麻烦,而且input组件会有bug(限制不了数字长度);
3.和饿了么比起来,他的提示系列没有服务的方式。
4.上传组件很死板。
5.审美的话,估计不是迎合国内的,我感觉不舒服。
二、自定义的部分组件
1.公共上传组件
代码如下(集成按钮上传和图片上传,可以校验图片的规则,网上很多是用原生的input写的方法,我这就是UI框架内置组件。):
<template>
<div>
<div v-if="uploadType === 'img'" class="d-flex justify-center">
<div
v-if="label"
class="mr-2 text-right input-label"
:style="{ width: labelWidth }"
>
<span v-if="rules && rules.length" class="error--text">*</span
>{{ label }}:
</div>
<v-hover v-slot="{ hover }">
<div
class="upload-warp"
:class="{
'error-submit':
$refs.file && $refs.file.validationState === 'error',
}"
:style="[upH, upW]"
>
<v-img
v-if="value !== '' && value"
:max-width="width"
:max-height="height"
:src="commonUrl + value"
/>
<span v-else class="icon-plus" @click="uploadBtnAction">+</span>
<v-overlay :value="upLoad" absolute>
<v-progress-circular indeterminate size="64"></v-progress-circular>
</v-overlay>
<v-file-input
v-if="!value"
ref="file"
style="display: none"
:value="fileValue"
class="upload-action"
:rules="rules"
:accept="accept"
truncate-length="15"
outlined
solo
full-width
filled
@change="uploadChange"
@update:error="updateError"
>
<template #message="{ message }">
<div class="err-msg">
{{ message }}
</div>
</template>
</v-file-input>
<v-overlay v-if="hover && value !== '' && value" absolute>
<div>
<v-icon class="mr-2" color="primary" @click="preview = true"
>mdi-eye</v-icon
>
<v-icon v-if="!disabled" color="error" @click="delImg"
>mdi-delete</v-icon
>
</div>
</v-overlay>
</div>
</v-hover>
<v-overlay
v-if="uploadType === 'img'"
:value="preview"
@click="preview = !preview"
>
<v-img :src="commonUrl + value"></v-img>
</v-overlay>
</div>
<div v-if="uploadType === 'btn'">
<!--文件上传-->
<v-file-input
ref="file"
v-model="fileValue"
:multiple="multiple"
style="display: none"
:accept="accept"
@change="uploadChange"
>
</v-file-input>
<v-btn
:loading="upLoad"
:color="btnType"
:disabled="disabled"
@click="uploadBtnAction"
>
<v-icon left>
{{ btnIcon }}
</v-icon>
{{ uploadText }}
</v-btn>
</div>
</div>
</template>
<script>
import httpConfig from '@/config/http.config';
export default {
name: 'UpLoad',
model: {
event: 'change',
prop: 'value',
},
props: {
value: { type: [String, Array, Object, Function], default: () => '' },
width: { type: [String, Number], default: () => '160px' },
height: { type: [String, Number], default: () => '160px' },
uploadType: { type: String, default: () => 'img' },
accept: { type: String, default: () => 'image/*' },
multiple: { type: Boolean, default: () => false },
rules: { type: Array, default: () => [] },
label: { type: String, default: () => '' },
labelWidth: { type: String, default: '100px' },
uploadText: { type: String, default: '上传' },
btnType: { type: String, default: 'primary' },
btnIcon: { type: String, default: 'mdi-upload' },
api: { type: [Object, Function], default: () => {}, required: true },
appendUrlParams: { type: Object, default: () => {} }, //请求额外参数
otherFormData: { type: Object, default: () => {} }, //文件额外参数
disabled: { type: Boolean, default: () => false },
},
data() {
return {
isError: false,
commonUrl: httpConfig.imgUrl,
preview: false,
upLoad: false,
fileValue: null,
};
},
computed: {
upH() {
const num = +this.height;
if (typeof num === 'number') {
return `height: ${num}px`;
} else {
return `height: ${num}`;
}
},
upW() {
const num = +this.width;
if (typeof num === 'number') {
return `width: ${num}px`;
} else {
return `width: ${num}`;
}
},
},
mounted() {
if (this.value) {
this.$emit('change', this.value);
}
},
methods: {
uploadBtnAction() {
console.log('$refs.file---**', this.$refs.file);
this.$refs.file.$refs.input.click();
},
async uploadChange(e) {
let file;
if (this.uploadType === 'btn') {
file = this.fileValue;
} else {
file = e;
}
console.log('file----', file);
const otherParams = this.appendUrlParams; //TODO 请求链接上的额外参数
const otherFormData = this.otherFormData; //TODO formData的额外参数
const formData = new FormData();
formData.append('file', file);
if (otherFormData && this.typeCheck(otherFormData, 'Object')) {
Object.keys(otherFormData).forEach((key) => {
formData.append(key, otherFormData[key]);
});
}
let appendUrlParams = '';
if (otherParams && this.typeCheck(otherParams, 'Object')) {
try {
appendUrlParams = `?${new URLSearchParams(
Object.entries(otherParams)
).toString()}`;
} catch {
Object.keys(otherParams).forEach((key, index) => {
if (index === 0) {
appendUrlParams = appendUrlParams.concat(
`?${key}=${otherParams[key]}`
);
} else {
appendUrlParams = appendUrlParams.concat(
`&${key}=${otherParams[key]}`
);
}
});
}
}
this.upLoad = true;
await this.api(formData, appendUrlParams)
.then(({ code, data, message }) => {
if (code === 200 || code === 0) {
this.fileValue = null;
this.upLoad = false;
this.$alert.success(message || '操作成功');
// if (data.length) {
// if (data[0].fileNames ?? data[0].fileNames[0] ?? false) {
// data[0] = data[0].fileNames[0];
// }
// }
this.$emit('uploadSuccess');
console.log('---***', data[0]);
this.$emit('change', data?.[0]);
}
})
.catch(() => {
this.fileValue = null;
this.upLoad = false;
});
},
typeCheck(val, type) {
return Object.prototype.toString.call(val) === `[object ${type}]`;
},
isEmptyObj(val) {
const arrKeys = Object.getOwnPropertyNames(val);
return arrKeys.length === 0;
},
delImg() {
this.$emit('change', '');
},
updateError(err) {
console.log('updateError---', err);
},
},
};
</script>
<style lang="scss" scoped>
.upload-warp {
display: flex;
position: relative;
background-color: #f9fbff;
border: 1px #2e71fe dashed;
border-radius: 5px;
overflow: hidden;
align-items: center;
width: 160px;
height: 160px;
.icon-plus {
position: absolute;
cursor: pointer;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 42px;
color: #2e71fe;
}
.upload-action {
position: absolute;
cursor: pointer;
//width: 100%;
//left: 50%;
//top: 50%;
//transform: translate(-50%, -50%);
z-index: 998;
opacity: 1;
}
}
.error-submit {
border-color: red !important;
}
.err-msg {
position: absolute;
width: 100%;
height: 100%;
}
::v-deep .v-text-field__details {
position: absolute;
width: 100%;
left: 0;
bottom: 0;
}
</style>
2.alert组件(组件/api调用均可)
alert.vue组件:
<template>
<v-alert
v-model="visible"
dense
dismissible
close-label="alert"
:type="type"
class="m-0 p-0 mx-auto"
style="
font-size: 14px;
pointer-events: auto;
position: fixed;
left: 0;
right: 0;
z-index: 99999;
"
:style="{ top: verticalOffset }"
min-width="300px"
max-width="600px"
transition="scale-transition"
@input="clickClose"
>
{{ content }}
</v-alert>
</template>
<script>
export default {
props: {
uuid: { type: String, default: '' },
color: { type: String, default: '' },
showClose: { type: Boolean, default: false },
},
data() {
return {
verticalOffset: 0,
messages: [],
visible: true,
timer: 0,
duration: 3000,
onClose: null,
content: '',
type: undefined,
closed: false,
};
},
watch: {
closed(newVal) {
if (newVal) {
this.verticalOffset = '0px';
this.visible = false;
// this.destroyElement();
this.$el.addEventListener('transitionend', this.destroyElement);
}
},
},
mounted() {
if (this.duration > 0) {
this.timer = setTimeout(() => {
if (!this.closed) {
this.close();
}
}, this.duration);
}
},
methods: {
clickClose() {
this.close();
},
destroyElement() {
this.$el.removeEventListener('transitionend', this.destroyElement);
this.$destroy(true);
this.$el.parentNode.removeChild(this.$el);
},
close() {
this.closed = true;
if (typeof this.onClose === 'function') {
this.onClose();
}
},
clearTimer() {
clearTimeout(this.timer);
},
startTimer() {
if (this.duration > 0) {
this.timer = setTimeout(() => {
if (!this.closed) {
this.close();
}
}, this.duration);
}
},
m_cancel() {
this.close();
this.$emit('cancel');
},
m_ok() {
this.close();
this.$emit('ok');
},
},
};
</script>
<style lang="scss" scoped>
.alert {
font-size: 14px;
position: absolute;
top: 0;
padding: 0;
width: 100%;
z-index: 100;
pointer-events: none;
}
.v-application {
background: transparent;
}
</style>
alert.js(api方式):
import Vue from 'vue';
import Vuetify from '@/plugins/vuetify';
import KAlert from '@/components/koi/KAlert.vue';
const alertConstructor = Vue.extend(KAlert);
let seed = 1;
let instance;
let instances = [];
let VNode = alertConstructor.constructor;
const isVNode = (obj) => obj instanceof VNode;
function create(comp, obj) {
let vm = new comp({ data: obj });
vm.$vuetify = Vuetify.framework;
vm = vm.$mount();
return vm;
}
const Alert = function (options) {
// eslint-disable-next-line no-debugger
// debugger;
options = Object.assign({}, options);
const id = 'alert' + seed++;
options.onClose = function () {
Alert.close(id);
};
instance = alertConstructor;
instance = create(instance, options);
instance.id = id;
instance.visible = true;
instance.dom = instance.$el;
let verticalOffset = options.offset || 0;
instances.forEach((item) => {
verticalOffset += item.$el.offsetHeight + 16;
});
verticalOffset += 16;
instance.verticalOffset = verticalOffset + 'px';
const app = document.getElementById('app');
app.appendChild(instance.$el);
instances.push(instance);
return instance;
};
['success', 'warning', 'info', 'error'].forEach((type) => {
Alert[type] = (options) => {
if (typeof options === 'string' || isVNode(options)) {
options = {
content: options,
};
}
options.type = type;
return Alert(options);
};
});
Alert.close = function (id, userOnClose) {
let index = -1;
// const len = instances.length;
const instance = instances.filter((instance, i) => {
if (instance.id === id) {
index = i;
return true;
}
return false;
})[0];
if (!instance) return;
if (typeof userOnClose === 'function') {
userOnClose(instance);
}
// eslint-disable-next-line no-debugger
// debugger;
instances.splice(index, 1);
};
export default Alert;
使用方法:
//main.js
import KAlert from '@/components/KAlert.js';
Vue.prototype.$alert = KAlert;
3.table组件
<template>
<div>
<v-data-table
:value="selection"
class="rounded-lg overflow-hidden"
:class="{ 'elevation-2': elevation }"
:headers="headers"
checkbox-color="primary"
:item-class="itemClass"
:items="items"
:show-select="showSelect"
no-data-text="暂无更多数据!"
:items-per-page="itemPerPage"
:item-key="itemKey"
:hide-default-footer="hideDefaultFooter"
:hide-default-header="hideDefaultHeader"
:show-expand="showExpand"
:expanded.sync="expanded"
:options.sync="options"
:sort-by.sync="sortBy"
:sort-desc.sync="sortDesc"
:single-expand="singleExpand"
:multi-sort="multiSort"
:page="modelValue"
@input="selectionChange"
>
<template #[`body.append`]="data">
<slot name="append" v-bind="data"></slot>
</template>
<template #[`top`]="data">
<slot name="top" v-bind="data"></slot>
</template>
<template #expanded-item="data">
<slot name="expanded-item" v-bind="data"></slot>
</template>
<template
v-for="slotName in columnsSlotsArr"
#[`item.${slotName}`]="data"
>
<slot
v-if="$scopedSlots[slotName]"
:name="slotName"
v-bind="data"
></slot>
</template>
</v-data-table>
<v-pagination
v-if="total > 0 && paginationshow"
v-model="modelValue"
class="mt-5"
next-icon="mdi-arrow-right-thin"
prev-icon="mdi-arrow-left-thin"
:length="pageLength"
:total-visible="6"
></v-pagination>
</div>
</template>
<script>
export default {
name: 'KCrudTable',
model: {
prop: 'selection',
event: 'change',
},
props: {
value: { type: [Number], default: 1 },
elevation: { type: [Boolean], default: true },
selection: { type: Array, default: () => [] },
itemPerPage: { type: Number, default: 10 },
total: { type: Number, default: 1 },
headers: { type: Array, default: null },
items: { type: Array, default: null },
itemKey: { type: String, default: '' },
showSelect: { type: Boolean, default: false },
itemClass: { type: String, default: '' },
hideDefaultFooter: { type: Boolean, default: true },
hideDefaultHeader: { type: Boolean, default: false },
showExpand: { type: Boolean, default: false },
singleExpand: { type: Boolean, default: false },
multiSort: { type: Boolean, default: false },
expanded: { type: Array, default: () => [] },
// options: { type: Array, default: () => [] },
pageChanged: { type: Function, default: () => {} },
sort: { type: Function, default: () => {} },
paginationshow: { type: Boolean, default: true },
sortBy: { type: [String, Array], default: () => '' },
sortDesc: { type: [String, Array], default: () => '' },
},
data() {
return {
modelValue: 1,
options: {},
};
},
computed: {
//todo插槽分发
columnsSlotsArr() {
const allSlots = this.headers.map((item) => item.value);
const scopeSlots = Object.keys(this.$scopedSlots);
return allSlots.filter((item) => scopeSlots.includes(item));
},
pageLength() {
return this.total % this.itemPerPage === 0
? this.total / this.itemPerPage
: Math.floor(this.total / this.itemPerPage) + 1;
},
},
watch: {
value() {
this.modelValue = this.value;
},
modelValue() {
this.$emit('input', this.modelValue);
this.$emit('pageChanged', this.modelValue);
},
options() {
this.$emit('sort', this.options);
},
},
mounted() {
this.modelValue = this.value;
},
methods: {
selectionChange(e) {
this.$emit('change', e);
},
},
};
</script>
<style lang="scss" scoped>
::v-deep
.theme--light.v-data-table
> .v-data-table__wrapper
> table
> tbody
> tr:hover:not(.v-data-table__expanded__content):not(.v-data-table__empty-wrapper) {
background: #f6f9fb !important;
}
::v-deep .v-data-table-header {
background: #f9f9f9;
}
::v-deep
.theme--light.v-data-table
> .v-data-table__wrapper
> table
> thead
> tr
> th {
font-size: 14px;
font-weight: 800;
color: #303133;
}
::v-deep.theme--light.v-data-table
> .v-data-table__wrapper
> table
> thead
> tr:last-child
> th {
border: none;
}
</style>
4.树形下拉选择组件
<template>
<div class="d-flex align-center my-3" :style="cssVars">
<div class="mr-2 text-right input-label">
<span v-if="rules && rules.length" class="error--text">*</span
>{{ label }}:
</div>
<v-menu
v-model="show"
no-data-text="暂无更多数据!"
:offset-y="true"
:bottom="true"
:close-on-content-click="false"
max-height="500px"
>
<template #activator="{ attrs, on }">
<v-text-field
v-model="treeName"
label=""
dense
:readonly="!hasInputSearch"
outlined
hide-details="auto"
clearable
v-bind="attrs"
:rules="rules"
:disabled="disabled"
class="input-field"
:placeholder="disabled ? '' : defaultPlaceholder"
@click:clear="onClear"
@blur="blurName"
v-on="on"
/>
</template>
<v-card>
<v-treeview
v-if="!hasCheckBox"
:active="value"
:search="hasInputSearch ? treeName : empty"
:filter="filter"
:item-key="treeKey"
:item-text="treeLabel"
:item-children="treeChildrenKey"
selection-type="independent"
transition
activatable
:open-on-click="openOnClick"
:items="items"
selected-color="primary"
@update:active="activeItem"
></v-treeview>
<v-treeview
v-if="hasCheckBox"
:value="value"
:search="hasInputSearch ? treeName : empty"
:filter="filter"
:item-key="treeKey"
:item-text="treeLabel"
:item-children="treeChildrenKey"
transition
:open-on-click="openOnClick"
:items="items"
selectable
selected-color="primary"
@input="checkItem"
></v-treeview>
</v-card>
</v-menu>
</div>
</template>
<script>
export default {
model: {
prop: 'value',
event: 'change',
},
props: {
rules: { type: Array, default: () => [] },
items: { type: Array, default: () => [] },
value: { type: [Array, null], default: () => [] },
label: { type: String, default: '' },
treeLabel: { type: String, default: 'name' }, //树形显示名称
treeKey: { type: String, default: 'id' }, //树形唯一ID
treeChildrenKey: { type: String, default: 'children' }, //树形子集Key
placeholder: { type: String, default: '' },
labelWidth: { type: String, default: '100px' },
inputWidth: { type: String, default: '200px' },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
openOnClick: { type: Boolean, default: false }, //点选父级展开子项,父级不选
hasCheckBox: { type: Boolean, default: false }, //是否多选框
hasInputSearch: { type: Boolean, default: true }, //是否模糊搜索
},
data: () => ({
empty: '',
activeVal: [],
show: false,
selection: [],
checkName: [],
tree: [],
treeName: null,
treeData: [],
}),
computed: {
filter() {
return (item, search, textKey) => item[textKey].indexOf(search) > -1;
},
defaultPlaceholder() {
return this.placeholder === '' ? '请选择' + this.label : this.placeholder;
},
cssVars() {
return {
'--labelWidth': this.labelWidth,
'--inputWidth': this.inputWidth,
};
},
},
watch: {
value: {
immediate: true,
handler(v) {
if (v) {
this.selection = v;
this.checkName = [];
this.showName(this.items);
}
},
},
},
mounted() {
if (this.value) this.$emit('change', this.value);
// this.selection = this.value;
// this.checkName = [];
// this.showName(this.items);
},
methods: {
blurName() {
// if (!this.selection.length) {
// this.treeName = null;
// }
},
showName(data) {
const treeData = this.selection || [];
const label = this.treeLabel;
const value = this.treeKey;
const children = this.treeChildrenKey;
data.map((item) => {
if (treeData.includes(item[value])) {
if (!this.checkName.includes(item[label])) {
this.checkName.push(item[label]);
}
} else if (item[children]?.length) {
this.showName(item[children]);
}
});
this.treeName = this.checkName.toString();
},
activeItem(val) {
this.treeName = null;
this.checkName = [];
this.selection = val;
this.showName(this.items);
if (val.length > 0) {
setTimeout(() => {
this.show = false;
}, 500);
}
this.$emit('change', val);
},
checkItem(val) {
this.treeName = null;
this.checkName = [];
this.selection = val;
this.showName(this.items);
// if (val.length > 0) {
// setTimeout(() => {
// this.show = false;
// }, 500);
// }
this.$emit('change', val);
},
onClear() {
this.treeName = null;
this.checkName = [];
this.selection = [];
this.$emit('change', []);
},
},
};
</script>
<style lang="scss" scoped>
.input-field {
width: var(--inputWidth);
}
.input-label {
width: var(--labelWidth);
}
//让错误浮动的显示在入力框下面,并不占用页面空间。
::v-deep .v-text-field__details {
position: absolute;
top: 3em;
}
::v-deep .v-treeview-node__label {
font-size: 14px;
}
</style>
总结
这些组件都是自己在项目实际需求中自己封装,码字不易,看完/用完记得点个赞再走~~~~