Vue + Vuetify使用感受以及部分自定义组件


前言

这次用了个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>

总结

这些组件都是自己在项目实际需求中自己封装,码字不易,看完/用完记得点个赞再走~~~~

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值