Vue生态 UI 框架

重要通知

提供基于Vue生态体系可以参考借鉴的UI框架最佳实践指引与核心组件功能的自定义实现。

element-plus

Element是一套为开发者、设计师和产品经理准备的基于Vue的桌面端组件库,别称为网站快速成型工具。

安装配置

按需引入-手动导入

Element Plus 提供了基于 ES Module 开箱即用的 Tree Shaking 功能。

  • 安装配置
> npm i unplugin-element-plus -D
  • vite.config.ts
import { defineConfig } from 'vite';
import ElementPlus from 'unplugin-element-plus/vite';

plugins: [ElementPlus()]
  • 业务组件
<template>
	<el-button>I am ElButton</el-button>
</template>
<script>
	import { ElButton } from 'element-plus';

	export default {
		components: { ElButton },
	}
</script>
  • Vue3语法示例
<template>
  <el-button>Default</el-button>
</template>

<script lang="ts" setup>
  import { Button } from '@element-plus/icons-vue';
</script>

ICON图标系统

<template>
  <section>
    <el-icon><Document /></el-icon>
  </section>
</template>

<script lang="ts" setup>
  import { Document, Icon } from '@element-plus/icons-vue';
</script>
  • 模块导入列表
import { 
  ElButton 
} from 'element-plus';

el-popover

<el-popover ref="elPopoverRef"></el-popover>

<script lang="ts" setup>
  // 手动关闭
	elPopoverRef.value?.hide()
</script>

el-cascader 级联选择器

nextTick(() => {
	const $el = document.querySelectorAll('.el-cascader-panel .el-cascader-node[aria-owns]')
	Array.from($el).map((item) => item.removeAttribute('aria-owns'))
})

<el-table>表格

多选功能


单选功能

<script setup lang="ts">
  const initList: Record<string, any>[] = [
    // 多选
    { type: "selection" },

    // 单选,具体参考List/fitness_admin_fe/src/widgets/recommend/components/select-action/config.ts
    // List/fitness_admin_fe/src/components/table/table-list.vue
    { type: "selection", mark: "radio" }
  ]
</script>

<script setup lang="ts">
  // 表格配置
  const initTableColumns = computed<Record<string, any>[]>(() => {
    (props.tableColumns as Record<string, any>[]).forEach(
      (item: Record<string, any>) => {
        item.align ??= "center";
        item.ellipsisTooltip ??= false;

        // 多选
        if (item.type === "selection") {
          item.width ??= 50;
        }

        // 单选
        if (item.type === "selection" && item.mark === "radio") {
          item.className = "radio";
        }
      }
    )
  });
</script>


<style lang="scss">
/** 单选 */
.el-table {
  > .el-table__inner-wrapper {
    .el-table__header-wrapper {
      > .el-table__header {
        .el-table__cell.radio {
          .el-checkbox {
            &::before {
              content: "单选";
              color: var(--el-table-header-text-color);
            }
            .el-checkbox__input {
              display: none;
            }
          }
        }
      }
    }
    .el-table__body-wrapper {
      .el-table__body {
        .el-table__row {
          .el-table__cell.radio {
            .el-checkbox {
              .el-checkbox__inner {
                border-radius: 100%;
              }
            }
          }
        }
      }
    }
  }
}
</style>

问题反馈

  • <el-table>需要添加key标志才能促使<el-table-column>因为遍历的数据而更新,即<el-table :key='变化值'>

Feedback 反馈组件

ElMessageBox

const action = await ElMessageBox.confirm(
  "你确定要退出当前登录吗?",
  "操作提示",
  {
    confirmButtonText: "确定"
  }
)
  .then(() => true)
  .catch(() => false);

if (!action) return;

<el-image-viewer>

<template>
  <el-image-viewer
    v-if="imageViewer.visual"
    hide-on-click-modal
    :url-list="imageViewer.list"
  />
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import { ElImage, ElImageViewer } from 'element-plus'

const imageViewer = reactive({
  visual: false,
  list: []
});
</script>

<el-dialog>

<!--
	<component-dialog v-model='visible' @update:submit="submitBackhaul" @update:action="actionBackhaul" />

	const componentDialog = defineAsyncComponent(() => import('@/components/concat-account.vue'))
-->
<template>
	<el-dialog
		v-model="initValue"
		:before-close="close"
		:close-on-click-modal="false"
		title="报告上传"
		width="700px"
		class="com-erp-dialog define-"
	>
		<template #footer>
			<span class="dialog-footer">
				<el-button @click="close()">取消</el-button>
				<el-button :loading="submitLoading" type="primary" :disabled="disabledSubmit" @click="confirm()">
					确定
				</el-button>
			</span>
		</template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ElMessage, ElDialog } from 'element-plus';

const props = defineProps({
	modelValue: {
		type: Boolean,
		default() {
			return false;
		}
	}
});

const emit = defineEmits(['update:modelValue', 'action:submit', 'action:action']);

// 限制重复提交
const submitLoading = ref(false);
// 限制上传尚未完成
let complete = ref(true);

const initValue = computed({
	get: () => props.modelValue,
	set: (val) => {
		emit('update:modelValue', val);
	}
});

const disabledSubmit = computed(() => !complete.value);

// 提交表单
async function confirm() {
	ElMessage.closeAll();

	if (submitLoading.value) {
		return ElMessage.warning('正在提交中,请不要重复提交');
	}
}

// 关闭模态框
function close() {
	emit('update:modelValue', false);
}
</script>

<style lang="scss">
@import '@/assets/style/dialog.scss';
</style>

<style lang="scss" scoped></style>

<el-scrollbar>

<el-scrollbar></el-scrollbar>

<style lang="scss" scoped>
  /* 不设置固定高度,当内容超过一定高度后,自动使用滚动 */
	:deep(.el-scrollbar__wrap) {
		max-height: 200px;
	}
</style>

<el-pagination>

<template>
  <!--分页模块-->
  <component-table-pagination
    v-if="handleColumns.length && !hidden?.pagination"
    v-model:current-page="paging.currentPage"
    v-model:page-size="paging.limit"
    :total="paging.totalCnt"
    @update:callback="uploadPaginationCallback"
  />
</template>

<script lang="ts" setup>
// 第三方资源类库
import { ref, reactive, computed } from 'vue';

import componentTablePagination from './table-pagination.vue';

/**
 * @当前业务
 * 具体逻辑实现代码
 */
</script>

<!--当前页面作用域-->
<style lang="scss" scoped>
.page-module {
	position: relative;
}
</style>

<template>
	<div class="component-el-pagination" :class="{ null: !total }">
		<template v-if="total">
			<el-pagination
				v-model:current-page="initCurrentPage"
				v-model:page-size="initPageSize"
				popper-class="component-el-pagination-table-list"
				background
				layout="total, sizes, prev, pager, next, jumper"
				:page-sizes="TABLE_PAGINATION_PAGE_SIZES"
				:total="total"
				@size-change="sizeChange"
				@current-change="updateCondition"
			/>
		</template>
	</div>
</template>

<script lang="ts" setup>
import { computed } from 'vue';
import { ElPagination } from 'element-plus';

const props = defineProps({
	total: {
		type: Number,
		default() {
			return 0;
		}
	},
	currentPage: {
		type: Number,
		default() {
			return 1;
		}
	},
	pageSize: {
		type: Number,
		default() {
			return 20;
		}
	}
});

const emit = defineEmits(['update:current-page', 'update:page-size', 'update:callback']);

const TABLE_PAGINATION_PAGE_SIZES = [20, 50, 100, 200];

const initCurrentPage = computed({
	get: () => props.currentPage,
	set: (value: number) => {
		emit('update:current-page', value);
	}
});

const initPageSize = computed({
	get: () => props.pageSize,
	set: (value: number) => {
		emit('update:page-size', value);
	}
});
const sizeChange = () => {
	emit('update:current-page', 1);
	updateCondition();
};
const updateCondition = () => {
	emit('update:callback');
};
</script>

<style lang="scss" scoped>
.component-el-pagination {
	background-color: #ffffff;
	overflow: hidden;
	position: relative;
	padding-top: 12px;
	padding-bottom: 12px;
	padding-right: 12px;

	&.null {
		padding-bottom: 3px;
	}

	:deep(.el-pagination) {
		float: right;

		.btn-next,
		.btn-prev,
		.el-pager li {
			background-color: #fff;
			margin: 0 4px;

			&.is-active {
				background-color: #409eff;
			}

			&:not(.is-active) {
				border: 1px solid #b3c0e7;
			}
			font-weight: 400;
		}
		.el-pagination__total {
			margin-right: 5px;
		}
		.el-pagination__sizes {
			margin-right: -1px;
		}
		.el-select {
			.el-input__inner {
				border-color: #b3c0e7;
			}
		}
		.el-pagination__jump {
			margin-left: 20px;
			.el-input--mini {
				width: 50px;
			}
			.el-pagination__editor {
				margin-left: 8px;
				margin-right: 8px;
			}
			.el-input__wrapper {
				.el-input__inner {
					border: 0;
				}
			}
		}
	}
}
</style>

<el-select-v2>

该组件v-model="selectOptions.value"中selectOptions.value === ""空字符串时,palceholder不显示,只有为null或undefined时才显示。

<el-form>表单

抽离<el-form-item>

  • 原有冗余结构
<template>
  <el-form ref="ruleFormRef" :model="ruleForm" label-width="120px">
    <el-form-item label="课程名称:" prop="title" :rules="rules.title">
      <el-input
        v-model="ruleForm.title"
        class="name"
        clearable
        style="width: 280px"
        placeholder="请输入内容"
      />
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { ElForm, ElFormItem, ElInput } from "element-plus"; 
import type { FormInstance, FormRules } from "element-plus";

const ruleFormRef = ref<FormInstance>();
const ruleForm = reactive({
  title: ""
});

const rules = reactive<FormRules>({
  title: [
    { required: true, message: "请输入课程名称", trigger: ["blur", "change"] }
  ]
});

// 验证器
async function formValidater() {
	return new Promise((resolve) => {
		ruleFormRef.value?.validate((valid: boolean) => {
			resolve(valid);
		});
	});
}


// 提交表单
async function submitAction() {
  const validater = await formValidater();
  if (!validater) return;

	ElMessage.closeAll();

	if (submitLoading.value) {
		ElMessage.warning('正在提交中,请稍后再操作!');
		return;
	}

	submitLoading.value = true;

  // 提交参数
  const submitOptions: Record<string, any> = {};
}
</script>
  • 分离后友好结构
<!--父级组件-->
<template>
  <el-form ref="ruleFormRef" :model="ruleForm" label-width="120px">
    <child
      v-model="ruleForm.title"
    />
  </el-form>
</template>

<script setup lang="ts">
  import child from "./components/child.vue";
</script>


<!--子级组件-->
<!--
  import child from "./components/child.vue";
-->
<template>
  <el-form-item label="课程名称:" prop="title" :rules="rules.title">
    <el-input
      v-model="initValue"
      class="name"
      clearable
      style="width: 280px"
      placeholder="请输入内容"
    />
  </el-form-item>
</template>

<script setup lang="ts">
// 第三方资源类库或插件
import { reactive, computed } from "vue";
import { ElFormItem, ElInput } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";

// 全局资源库

// 局部资源库

/**
 * @当前业务
 * 具体逻辑实现代码
 */
const props = defineProps({
  modelValue: {
    type: String,
    default() {
      return "";
    }
  }
});

const emit = defineEmits(["update:modelValue"]);

const ruleFormRef = ref<FormInstance>();
const rules = reactive<FormRules>({
  title: [
    { required: true, message: "请输入课程名称", trigger: ["blur", "change"] }
  ]
});

const initValue = computed({
  get: () => props.modelValue,
  set: (val) => {
    emit("update:modelValue", val);
  }
});
const initRules = computed<any[]>(() => props.rules);
</script>

表单提交验证

<template>
	<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules" label-width="120px" status-icon>
	</el-form>

	<template #footer>
		<span class="dialog-footer">
			<el-button @click="close">取消</el-button>
			<el-button type="primary" :loading="submitLoading" @click="submitForm(ruleFormRef, 1)">发布</el-button>
		</span>
	</template>
</template>

<script lang="ts" setup>
// 第三方资源类库
import { ElForm, ElFormItem, ElTooltip, ElImage, ElLink, ElInput } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';

// 全局资源库

/**
 * @当前业务
 * 具体逻辑实现代码
 */
const submitLoading = ref(false);
const ruleFormRef = ref<FormInstance>();
const ruleForm = reactive({});
const rules = reactive<FormRules>({});

const submitFormFunc = async (
  formEl: FormInstance | undefined,
  submitType: number
) => {
  if (!formEl) return;

  // 对整个表单的内容进行验证。 接收一个回调函数,或返回 Promise。
  const validate = await formEl.validate((valid: boolean, invalidFields) => {
    // 无效选项
    if (invalidFields && Object.entries(invalidFields).length) {
      const message =
      Object.entries(invalidFields)[0]?.[1]?.[0]?.message ||
      "提示消息内容!";

      ElMessageBox.alert(message, "消息提示", {
        confirmButtonText: "确定"
      });
    }

    return valid;
  });
  if (!validate) return;

  // 清理某个字段的表单验证信息。
  ruleFormRef.value?.clearValidate('group_id');	
  // 验证具体的某个字段。
  ruleFormRef.value?.validateField();
  // 重置该表单项,将其值重置为初始值,并移除校验结果
  ruleFormRef.value?.resetFields();
  // 滚动到指定的字段
  ruleFormRef.value?.scrollToField();

  
  submitLoading.value = true;

  // 请求参数
  const params: Record<string, any> = {
    //
  };
}
</script>

表单动态required赋值

// 解决Element-plus表单动态required赋值问题

watchEffect(() => {
  // 动态更新订单号是否为必填
  if ((rules.order_sn as any)?.[0]) {
    (rules.order_sn as any)[0].required = Number(props.need_order) === 1;
  }

  window.setTimeout(() => {
    ruleFormRef.value?.clearValidate('activity_id');
    ruleFormRef.value?.clearValidate('phone');
    ruleFormRef.value?.clearValidate('order_sn');
    ruleFormRef.value?.clearValidate('kf_name');
    ruleFormRef.value?.clearValidate('deadline');
    ruleFormRef.value?.clearValidate('skip_condition');
    ruleFormRef.value?.clearValidate('force_failed');
  });
});

列表添加删除动态校验

<template>
	<el-form ref="ruleFormRef" :model="ruleForm" label-width="120px" status-icon>
		<el-form-item label="大类名称:" prop="name" :rules="rules.name">
			<el-input v-model="ruleForm.name" class="name" placeholder="请输入大类名称
				" clearable />
		</el-form-item>

		<el-form-item label="大类难度:" class="define-asterisk aux-asterisk-style">
			<ul class="difficulty-content">

				<li v-for="(dx, index) in ruleForm.difficultys" :key="dx.uuid" class="com-flex">

          <!--这里需要特别注意: :prop的值必须是标签<el-form :model="ruleForm">上ruleForm中的变量链,尤其是组件嵌套时需要注意-->
          <!--rules则毫无关系,可以自由写-->
					<el-form-item :prop="'difficultys.' + index + '.value'" :rules="rules.difficultys">
						<el-input v-model="dx.value" placeholder="请输入大类难度" clearable />
					</el-form-item>
					<div v-if="index > 0" class="com-flex h center delete">
						<el-icon size="18" @click="deleteItem(dx, index)">
							<Delete />
						</el-icon>
					</div>
					<div v-if="index === ruleForm.difficultys.length - 1" @click="addItem(dx, index)"
						class="com-flex h center add">
						<el-icon size="18">
							<CirclePlusFilled />
						</el-icon>
						添加
					</div>
				</li>

			</ul>
		</el-form-item>
	</el-form>
</template>

<script lang="ts" setup>
	// 第三方资源类库或插件
	import { ref, computed, reactive, watchEffect } from "vue";
	import {
		ElDialog,
		ElForm,
		ElFormItem,
		ElInput,
		ElButton,
		ElScrollbar,
		ElMessage,
		ElMessageBox,
		ElIcon
	} from "element-plus";
	import { CirclePlusFilled, Delete } from "@element-plus/icons-vue";
	import type { FormInstance, FormRules } from "element-plus";

	const DIFFICULTY_TEMP = () => {
		return {
			uuid: uuid(),
			value: ""
		}
	}

  const ruleFormRef = ref();

	const ruleForm = reactive({
		difficultys: [
			DIFFICULTY_TEMP()
		],
    course: {
      control: true,
      list: [
        { weight: "" }
      ]
    }
	});

	const rules = reactive<FormRules>({
		difficultys: [{ required: true, message: "请输入大类难度", trigger: ["blur", "change"] }],
		group_id: [{ required: true, validator: validatorRules, message: '请选择销售组别', trigger: ['blur', 'change'] }]
	});

  /**
   * 验证器
   * @param rule 规则对象
   * @param value 选项值
   * @param callback 回调函数
   */
  function weightValidator(rule: any, value: any, callback: any) {
    const indexString = rule.field
      .replaceAll("course.list.", "")
      .replaceAll(".weight", "");
    const index = Number(indexString);

    const item = course.value.list[index] || {};

    const { message } = (rules?.rest_time as any)?.[0];
  }

  // 自定义验证规则
  function validatorRules(
    rule: Record<string, any>, 
    value: any, // 即v-model值
    callback: any
  ) {
    const { field, message } = rule

    const filedsRule = ruleForm.filter((item) => item.name === field)
    if (!filedsRule.length) return;

    const filedRule = filedsRule[0];
    const filedValue = ['target_name'].includes(filedRule.name) ? filedRule.subject : filedRule.value

    if ([undefined, null, ''].includes(filedValue)) {
      callback(new Error(message));
      return;
    }

    callback()
  }

  // 清除某个字段验证
  ruleFormRef.value?.clearValidate('group_id')


  // 对列表中某一个选项校验或清除,根据:prop配置来赋值
  ruleFormRef.value?.clearValidate("course.list.0.weight");
  ruleFormRef.value?.validateField("course.list.0.weight");

  :prop=`'course.list.' + ${index} + '.weight'`
  :rules="rules.median"
</script>

<style lang="scss" scoped>
.aux-asterisk-style {
  > :deep(.el-form-item__label) {
    &::before {
      right: 85px;
    }
  }
}

:deep(div.define-asterisk) {
  >.el-form-item__label {
    position: relative;

    &::before {
      content: "*";
      position: absolute;
      top: 50%;
      margin-top: -4px;
      padding-top: 2px;
      width: 8px;
      height: 8px;
      line-height: 8px;
      text-align: center;
      color: rgba(255, 77, 79, 1);
    }
  }
}
</style>

水印

<template>
  <el-form ref="ruleForm" :model="ruleForm" :rules="rules">
    <el-checkbox v-model="watermark.font">文字水印</el-checkbox><span v-if="!watermarkType" class="type-alt">文字水印与用户水印至少选择一个</span>
    <el-form-item class="watermark-item" prop="fontText">
      <el-input
        class="item-input"
        :maxlength="15"
        show-word-limit
        v-model="formDate.fontText"
        placeholder="请输入文字水印"
      ></el-input>
    </el-form-item>


    <div class="com-flex">
      <el-form-item label="处理方式:" prop="resource">
        <el-radio-group v-model="ruleForm.resource">
          <el-radio label="1">通过</el-radio>
          <el-radio label="0">驳回</el-radio>
        </el-radio-group>
        
      </el-form-item>
      <el-form-item prop="content" style="margin-left:25px;">
        <el-input v-if="ruleForm.resource == '0'" v-model="ruleForm.content" placeholder="请输入驳回说明" style="width:400px;"></el-input>
      </el-form-item>  
    </div>

    <el-row type="flex" justify="end">
      <el-button @click="dialogFlag = false">取消</el-button>
      <el-button type="primary" :loading="watermark.submit" @click="submitForm">确定</el-button>
    </el-row>  

  </el-form>
</template>

<script>
  import Vue from 'vue'
  import Component from 'vue-class-component'


  // @Component({ components: {} })
  @Component
  export default class Super extends Vue {
    name = '';

    watermark = {
      submit: false,
      id: null,
      edit: false,
      viewer: false,
      font: true,
      user: false,
      radio: '1',
      module: null,
      number: null,
    }

    ruleForm = {
      fontText: '',
      resource: '',
      content: '',
    };

    rules = {
      fontText: {
        validator: (rule, value, callback) => {
          if (this.watermark.font && (!value || value === '')) {
            return callback('文字水印不能为空');
          }
          callback();
        },
        trigger: 'blur',
      },
      resource: [
        { required: true, message: '请选择处理方式', trigger: 'change' }  // trigger: ['blur', 'change']
      ],
      content: {
        validator: (rule, value, callback) => {
          if (Number(this.ruleForm.resource) == 0 && (!value || value == '')) {
            return callback('请输入驳回说明');
          }
          callback();
        },
        trigger: 'blur',
      }
    }

    get watermarkType() {
      return this.watermark.font || this.watermark.user;
    }

    submitFormFunc() {
      this.$refs.formBox.validate(valid => {
        if (valid) {
          let url = this.$URL.createCategory;
          if (this.formDate.id) {
            url = this.$URL.getCategoryUpdate;
          }
          this.formDate.type = this.$route.query.type;

          let options = {};
          // 水印
          if (this.typeId == 205) {
            if (!this.watermarkType) {
              return;
            }
            if (this.watermark.submit) {
              return;
            }

            url = 'api/watermark/edit';
            options = {
              title: this.formDate.fontText,
              is_text: this.watermark.font ? 1 : -1,
              is_user: this.watermark.user ? 1 : -1,
              style: Number(this.watermark.radio),
              module: this.watermark.module,
              number: this.watermark.number,
            }
            options['id'] = this.watermark.id;
          } else {
            options = this.formDate;
          }

          this.watermark.submit = true;
          this.$API.post(url, options, result => {
            this.watermark.submit = false;
            if (result.code == 0) {
              this.$message.success('操作成功');
              this.dialogFlag = false;
              this.getCategoryType();
            } else {
              this.$message.error(result.msg);
            }
          }, () => {
            this.watermark.submit = false;
          });
        }
      });
    }

    mounted() {
      this.$nextTick(() => {});
    }

    created() {
      //
    }

  }
</script>

<style lang='less' scoped>
  @media only screen and (max-width: 1680px) {

  }
</style>

其他移动端UI框架

Vant

Mint UI

  • Mint UI:http://mint-ui.github.io/
  • https://github.com/ElemeFE/mint-ui

其他框架

  • MUI: https://mui.com/
  • Vuetify: https://vuetifyjs.com/

前端UI框架

  • Ant Design Mobile
  • NutUI
  • Varlet
  • Arco Design Mobile
Last Updated:
Contributors: 709992523, Eshen