UniApp快速表单组件

环境:vue3 + uni-app
依赖库:uview-plus、dayjs

通过配置项快速构建 form 表单

使用

<script setup>
  import CustomCard from '@/components/custom-card.vue';
  import { ref } from 'vue';
  import CustomFormItem from '@/components/form/custom-form-item.vue';
  import { formatDate } from '@/sheep/util';
  import { useForm } from '@/components/form/useForm';

  const formData = ref({
    useName: undefined,
    areaValue: undefined,
    address: undefined,
  });
  const formItems = [
    [{
      label: '企业名称',
      prop: 'useName',
      required: true,
    }, {
      label: '所属地区',
      type: 'area',
      prop: 'areaValue',
      required: true,
    }],
    [{
      label: '企业规模',
      prop: 'scale',
    }, {
      label: '成立日期',
      type: 'date',
      prop: 'startDate',
      elAttrs: {
        formatter(value) {
          return formatDate(value, 'YYYY-MM-DD');
        },
      },
    }, {
      label: '说明',
      type: 'custom',
      prop: 'tip',
    }],
  ];

  const formConfig = {
    elAttrs: { border: 'none', inputAlign: 'right' },
    itemAttrs: { borderBottom: true },
    labelSuffix: ':',
  };

  const form = useForm(formItems, formConfig);

  function handleAreaChane(item, info) {
    formData.value[item.prop] = [info.province_name, info.city_name, info.district_name].join(' / ');
  }

  function handleDatetimeChange(item, data) {
    formData.value[item.prop] = formatDate(data.value);
  }
</script>

<template>
  <u--form
    ref="formRef"
    :model="formData"
    :rules="form.formRules"
    labelWidth="auto"
    :labelStyle="{marginLeft: '4px'}">
    <custom-card padding="0 14px">
      <custom-form-item
        v-model="formData"
        :items="form.itemList[0]"
        @area-change="handleAreaChane"></custom-form-item>
    </custom-card>
    <custom-card padding="0 14px">
      <custom-form-item
        v-model="formData"
        :items="form.itemList[1]"
        @datetime-change="handleDatetimeChange">
        <template #tip>
          <view style="width: 100%; text-align: right">这里是自定义插槽内容</view>
        </template>
      </custom-form-item>
    </custom-card>
  </u--form>
</template>

<style scoped lang="scss">

</style>

关于 formatter 方法,是用于格式化显示内容的方法,不会影响绑定值的变化,目前仅 choose、date、datetime、area类型支持

源码

useForm.js 文件

核心form工具类,用于解析、转换配置

/**
 * 是否数组
 */
function isArray(value) {
  if (typeof Array.isArray === 'function') {
    return Array.isArray(value);
  }
  return Object.prototype.toString.call(value) === '[object Array]';
}

/**
 * @description 深度克隆
 * @param {object} obj 需要深度克隆的对象
 * @returns {*} 克隆后的对象或者原值(不是对象)
 */
function deepClone(obj) {
  // 对常见的“非”值,直接返回原来值
  if ([null, undefined, NaN, false].includes(obj)) return obj;
  if (typeof obj !== 'object' && typeof obj !== 'function') {
    // 原始类型直接返回
    return obj;
  }
  const o = isArray(obj) ? [] : {};
  for (const i in obj) {
    if (obj.hasOwnProperty(i)) {
      o[i] = typeof obj[i] === 'object' ? deepClone(obj[i]) : obj[i];
    }
  }
  return o;
}

/**
 * @description JS对象深度合并
 * @param {object} target 需要拷贝的对象
 * @param {object} source 拷贝的来源对象
 * @returns {object|boolean} 深度合并后的对象或者false(入参有不是对象)
 */
export function deepMerge(target = {}, source = {}) {
  target = deepClone(target);
  if (typeof target !== 'object' || typeof source !== 'object') return false;
  for (const prop in source) {
    if (!source.hasOwnProperty(prop)) continue;
    if (prop in target) {
      if (typeof target[prop] !== 'object') {
        target[prop] = source[prop];
      } else if (typeof source[prop] !== 'object') {
        target[prop] = source[prop];
      } else if (target[prop].concat && source[prop].concat) {
        target[prop] = target[prop].concat(source[prop]);
      } else {
        target[prop] = deepMerge(target[prop], source[prop]);
      }
    } else {
      target[prop] = source[prop];
    }
  }
  return target;
}

export const typeMap = {
  INPUT: 'input',
  INPUT_NUMBER: 'inputNumber',
  TEXTAREA: 'textarea',
  NUMBER: 'number',
  CHOOSE: 'choose',
  DATE: 'date',
  DATETIME: 'datetime',
  TIME: 'time',
  AREA: 'area',
};
export const perMap = {
  [typeMap.INPUT]: '请输入',
  [typeMap.INPUT_NUMBER]: '请输入',
  [typeMap.TEXTAREA]: '请输入',
  [typeMap.NUMBER]: '请填写',
  [typeMap.CHOOSE]: '请选择',
  [typeMap.DATE]: '请选择',
  [typeMap.DATETIME]: '请选择',
  [typeMap.TIME]: '请选择',
  [typeMap.AREA]: '请选择',
};

const defaultConfig = {
  // formItem 属性
  itemAttrs: {},
  // formItem 中内容属性
  elAttrs: {},
  // 校验规则
  rule: {},
  // 标签追加字符串
  labelSuffix: '',
  // 是否将 rule 放到 formItem 上
  isItemRule: true,
};

/**
 * form 快速生成
 * ---
 * <p>
 * elAttrs 具有的额外属性
 * - textareaAlign 文本域对齐方式
 * - formatter 格式化显示内容
 * - resourceIdField 上传文件的资源主键属性
 * </p>
 * ---
 * <p>
 * itemAttrs 具有的额外属性
 * - labelPosition 自定义 label 时 label 的位置,同时也是每个 item 的单独的属性
 * </p>
 * ---
 * @param {{
 *   label?: string,
 *   type?: typeMap[keyof typeMap],
 *   prop: string,
 *   required?: boolean,
 *   customLabel?: boolean,
 *   elAttrs?: {
 *     textareaAlign?: 'left' | 'right',
 *     numberAlign?: 'left' | 'right',
 *     formatter?: Function,
 *     resourceIdField?: string
 *   } | Object,
 *   itemAttrs?: {
 *     labelPosition: 'top' | 'left'
 *   } | Object,
 *   rule?: Object | Object[],
 *   showRender?: Function,
 *   itemAttrsRender?: Function,
 *   elAttrsRender?: Function,
 *   valueRender?: Function
 * }[]|{
 *   label?: string,
 *   type?: typeMap[keyof typeMap],
 *   prop: string,
 *   required?: boolean,
 *   customLabel?: boolean,
 *   elAttrs?: {
 *     textareaAlign?: 'left' | 'right',
 *     numberAlign?: 'left' | 'right',
 *     formatter?: Function,
 *     resourceIdField?: string
 *   } | Object,
 *   itemAttrs?: {
 *     labelPosition: 'top' | 'left'
 *   } | Object,
 *   rule?: Object | Object[],
 *   showRender?: Function,
 *   itemAttrsRender?: Function,
 *   elAttrsRender?: Function,
 *   valueRender?: Function
 * }[][]} items form 项
 * @param {{
 *   itemAttrs?: Object,
 *   elAttrs?: Object,
 *   rule?: Object,
 *   labelSuffix?: string,
 *   isItemRule?: boolean,
 * }} [config] 配置
 * @return {{formRules: *, itemList: *}}
 */
export function useForm(items, config) {
  const startTime = Date.now();

  const props = Object.assign({}, defaultConfig, config || {});

  const itemList = (items || []).map(item => relItem(item));
  const formRules = getFormRules();

  function relItem(item) {
    if (item instanceof Array) {
      return item.map(item => relItem(item));
    }
    const itemNew = deepClone(item);
    itemNew.originLabel = itemNew.label;
    itemNew.label = itemNew.originLabel + props.labelSuffix;
    if (!itemNew.type) itemNew.type = typeMap.INPUT;
    const itemAttrs = deepClone(props.itemAttrs || {});
    itemNew.itemAttrs = deepMerge(itemAttrs, itemNew.itemAttrs);
    itemNew.itemAttrs.required = itemNew.itemAttrs.required || (itemNew.required || false);
    const elAttrs = deepClone(props.elAttrs);
    itemNew.elAttrs = deepMerge(elAttrs, itemNew.elAttrs);
    itemNew.elAttrs.placeholder = relPlaceholder(itemNew);
    return itemNew;
  }

  function getFormRules() {
    const rules = {};
    itemList.forEach((item) => {
      doGetFormRules(item, rules);
    });
    return rules;
  }

  function doGetFormRules(item, rules) {
    if (item instanceof Array) {
      item.forEach(item => {
        doGetFormRules(item, rules);
      });
    }
    let rule = {};
    if (item.itemAttrs && item.itemAttrs.required) {
      let type = 'string';
      if ([typeMap.INPUT_NUMBER, typeMap.NUMBER].includes(item.type)) {
        // 数字类型
        type = 'number';
      }
      rule = {
        type,
        required: true,
        message: relPlaceholder(item),
        trigger: ['blur', 'change'],
      };
    }
    if (item.rule) {
      if (item.rule instanceof Array) {
        rule = item.rule;
      } else if (typeof item.rule === 'object') {
        let propsRule = {};
        if (props.rule && Object.keys(item.rule).length > 0) {
          propsRule = props.rule;
        }
        rule = deepMerge(rule, propsRule);
        rule = deepMerge(rule, item.rule);
      }
    }
    rules[item.prop] = rule;
  }

  function relPlaceholder(item) {
    const elAttrs = item.elAttrs;
    if (elAttrs.placeholder || elAttrs.placeholder === '') return elAttrs.placeholder;
    const perStr = perMap[item.type];
    if (perStr) {
      return perStr + item.originLabel;
    }
    return '';
  }

  // 将 rule 添加到 itemAttrs 中
  function setRuleToItem(items, ruleKeys) {
    if (items instanceof Array) {
      for (let item of items) {
        setRuleToItem(item, ruleKeys);
      }
    } else if (ruleKeys.includes(items.prop)) {
      const rule = formRules[items.prop];
      if (!items.itemAttrs) {
        items.itemAttrs = {};
      }
      if (rule instanceof Array) {
        items.itemAttrs.rules = rule;
      } else if (typeof rule === 'object') {
        items.itemAttrs.rules = [rule];
      }
    }
  }

  // 开启 formItem rule 时,将 rule 放到 item 中
  if (props.isItemRule) {
    const ruleKeys = Object.keys(formRules);
    for (let item of itemList) {
      setRuleToItem(item, ruleKeys);
    }
  }

  // // 将最后一个 formItem 下边框设置为 false
  // function setLastItemBottomBorderHide(items) {
  //   if (!items || items.length === 0) return;
  //   for (let i = 1; i <= items.length; i++) {
  //     if (i === items.length) {
  //       const item = items[i - 1];
  //       if (item) {
  //         if (!item.itemAttrs) {
  //           item.itemAttrs = {};
  //         }
  //         item.itemAttrs.borderBottom = false;
  //       }
  //     }
  //   }
  // }
  //
  // // 隐藏最后一个 formItem 下边框处理
  // if (props.hideLastItemBottomBorder) {
  //   if (itemList instanceof Array) {
  //     for (let items of itemList) {
  //       setLastItemBottomBorderHide(items);
  //     }
  //   } else {
  //     setLastItemBottomBorderHide(itemList);
  //   }
  // }

  console.log('useForm 处理完毕,耗时:', ((Date.now() - startTime) / 1000).toFixed(4) + '秒');

  return {
    itemList,
    formRules,
  };
}

export const commonFormStyleConfig = {
  elAttrs: {
    border: 'none',
    inputAlign: 'right',
    clearable: true,
    textareaAlign: 'right',
    numberAlign: 'right',
  },
  labelSuffix: ':',
  itemAttrs: { borderBottom: true, labelWidth: 'auto' },
};

/**
 * 传入表单 ref 校验表单
 * @param form 表单 ref
 * @return {Promise<void>}
 */
export async function validateForm(form) {
  await form.validate();
}

/**
 * 传入表单 ref 和字段名,校验指定字段
 * @param form 表单 ref
 * @param {...string} fields 字段名,可多个
 */
export function validateFields(form, ...fields) {
  for (let field of fields) {
    form.validateField(field);
  }
}

/**
 * 传入配置合并默认的配置
 * @param {{
 *   itemAttrs?: Object,
 *   elAttrs?: Object,
 *   rule?: Object,
 *   labelSuffix?: string,
 *   isItemRule?: boolean,
 * }} [config] 配置
 * @return {{elAttrs: {border: string, clearable: boolean, inputAlign: string, numberAlign: string, textareaAlign: string}, itemAttrs: {labelWidth: string, borderBottom: boolean}, labelSuffix: string}|Object}
 */
export function commonFormStyleConfigMerge(config) {
  let commonConfig = deepMerge({}, commonFormStyleConfig);
  if (commonConfig) {
    commonConfig = deepMerge(commonConfig, config);
  }
  if (commonConfig) {
    return commonConfig;
  }
  return commonFormStyleConfig;
}

custom-form-item.vue 文件

核心 form-item 组件,用于自动生成表单项

为什么没有 custom-form 组件?

其实本来有,但是在实际使用过程中发现,custom-form 局限性过大,以及无法做到深层插槽能力,所以去掉了,而使用原生的 form 以提高可用性。

<script setup>
  import CustomDatetimePicker from '@/components/custom-datetime-picker.vue';
  import { typeMap } from '@/components/form/useForm';
  import { computed, getCurrentInstance, ref, watch } from 'vue';
  import CustomIconInput from '@/components/form/custom-icon-input.vue';
  import CustomRegionPicker from '@/components/custom-region-picker.vue';

  const instance = getCurrentInstance();

  defineOptions({
    options: {
      virtualHost: true,
    },
  });

  const props = defineProps({
    modelValue: Object,
    items: Array,
  });

  const formData = ref({});
  const emits = defineEmits(['update:modelValue', 'choose', 'datetimeChange', 'areaChange', 'enterpriseChange']);
  const currentItem = ref(undefined);

  watch(() => props.modelValue, () => {
    formData.value = props.modelValue;
  }, {
    immediate: true,
    deep: true,
  });

  watch(() => formData.value, () => {
    emits('update:modelValue', formData.value);
  }, {
    deep: true,
  });

  const itemList = computed(() => {
    if (!props.items) return [];
    return props.items.map(item => {
      item.show = relItemShow(item);
      item.classList = relItemClass(item);
      item.elWrapperClassList = relElWrapperClass(item);
      // item.elAttrs = relElAttrs(item);
      item.attrs = relItemAttrs(item);
      return item;
    });
  });

  // 可显示的 formItem 的长度,用于判断是否显示底部边框
  const showLen = computed(() => {
    const showList = itemList.value.filter(item => item.show);
    if (!showList) return 0;
    return showList.length;
  });

  function handleDatetimeChange(item, data) {
    emits('datetimeChange', item, data);
    // 关闭日期选择
    instance.refs[`datetimePicker${item.prop}Ref`][0].close();
  }

  function handleChoose(item) {
    emits('choose', item);
  }

  function handleChooseArea(item) {
    instance.refs[`regionPicker${item.prop}Ref`][0].open();
  }

  function handleAreaChange(item, info) {
    emits('areaChange', item, info);
    instance.refs[`regionPicker${item.prop}Ref`][0].close();
  }

  // 解析是否显示formItem中的元素
  function relShow(item, type) {
    if (type instanceof Array) {
      if (!type.includes(item.type)) return false;
    } else if (item.type !== type) return false;
    return true;
  }

  // 解析是否显示formItem
  function relItemShow(item) {
    if (!item) return false;
    if (item.showRender && typeof item.showRender === 'function') {
      return item.showRender(formData.value, item);
    }
    return true;
  }

  // 解析formItem的class
  function relItemClass(item) {
    const classArr = [];
    if (item.customLabel) {
      classArr.push('custom-label');
      if (item.itemAttrs && item.itemAttrs.labelPosition) {
        classArr.push('custom-label-position-' + item.itemAttrs.labelPosition);
      }
    }
    const elAttrs = item.elAttrs;
    if (elAttrs) {
      if (elAttrs.textareaAlign) {
        classArr.push(`textarea-align-${elAttrs.textareaAlign}`);
      }
    }
    return classArr.join(' ');
  }

  // 解析formItem中组件容器的class
  function relElWrapperClass(item) {
    const classArr = [];
    const elAttrs = item.elAttrs;
    if (elAttrs) {
      if (elAttrs.textareaAlign) {
        classArr.push(`textarea-align-${elAttrs.textareaAlign}`);
      }
    }
    return classArr.join(' ');
  }

  // 解析组件属性
  function relElAttrs(item) {
    const attrs = {};
    const elAttrs = item.elAttrs;
    if (elAttrs && typeof elAttrs === 'object') {
      Object.assign(attrs, elAttrs);
    }
    const elAttrsRender = item.elAttrsRender;
    if (elAttrsRender && typeof elAttrsRender === 'function') {
      const attrsRes = elAttrsRender();
      Object.assign(attrs, attrsRes);
    }
    return attrs;
  }

  // 解析formItem属性
  function relItemAttrs(item) {
    const attrs = {};
    const itemAttrs = item.itemAttrs;
    if (itemAttrs && typeof itemAttrs === 'object') {
      Object.assign(attrs, itemAttrs);
    }
    const itemAttrsRender = item.itemAttrsRender;
    if (itemAttrsRender && typeof itemAttrsRender === 'function') {
      const attrsRes = itemAttrsRender();
      Object.assign(attrs, attrsRes);
    }
    return attrs;
  }

  // 解析数字组件对齐方式
  function relNumberStyle(item) {
    const style = {
      justifyContent: 'flex-start',
    };
    if (item.elAttrs) {
      if (item.elAttrs.numberAlign && item.elAttrs.numberAlign === 'right') {
        style.justifyContent = 'flex-end';
      }
    }
    return style;
  }
</script>

<template>
  <view class="custom-form-item-list">
    <view
      class="custom-form-item"
      v-for="(item, index) in itemList"
      :key="item.prop"
      :class="[item.classList, {'last-item': index + 1 >= showLen}]">
      <slot :name="`${item.prop}Top`" :item="item"></slot>
      <view class="custom-form-item-container">
        <view class="custom-label" v-if="item.customLabel">
          <slot :name="`${item.prop}Label`" :item="item"></slot>
        </view>
        <up-form-item
          :label="item.label"
          :prop="item.prop"
          v-bind="item.attrs"
          v-if="item.show"
          :border-bottom="index + 1 < showLen">
          <view
            v-if="item.type !== 'custom'"
            class="form-item-el-wrapper"
            :class="[item.elWrapperClassList]">
            <up-input
              v-if="relShow(item, typeMap.INPUT)"
              v-model="formData[item.prop]"
              v-bind="relElAttrs(item)"></up-input>
            <up-input
              v-if="relShow(item, typeMap.INPUT_NUMBER)"
              v-model.number="formData[item.prop]"
              v-bind="relElAttrs(item)"></up-input>
            <up-textarea
              v-if="relShow(item, typeMap.TEXTAREA)"
              v-model="formData[item.prop]"
              v-bind="relElAttrs(item)"></up-textarea>
            <view
              class="form-item-flex"
              :style="relNumberStyle(item)"
              v-if="relShow(item, typeMap.NUMBER)">
              <up-number-box
                :name="item.prop"
                v-model="formData[item.prop]"
                v-bind="relElAttrs(item)"
                inputWidth="84rpx"
                bgColor="transparent"
                iconStyle="font-size: 20rpx;">
              </up-number-box>
            </view>
            <custom-icon-input
              v-else-if="relShow(item, typeMap.CHOOSE)"
              @click="handleChoose(item)"
              v-model="formData[item.prop]"
              v-bind="relElAttrs(item)">
              <template #suffix>
                <slot :name="`${item.prop}ChooseSuffix`"></slot>
              </template>
            </custom-icon-input>
            <template v-else-if="relShow(item, typeMap.AREA)">
              <custom-icon-input
                @click="handleChooseArea(item)"
                v-model="formData[item.prop]"
                v-bind="relElAttrs(item)">
              </custom-icon-input>
              <custom-region-picker
                :ref="`regionPicker${item.prop}Ref`"
                :model-value="item.valueRender ? item.valueRender(formData[item.prop], item) : []"
                @confirm="(info) => handleAreaChange(item, info)"
                v-bind="relElAttrs(item)" />
            </template>
            <custom-datetime-picker
              v-else-if="relShow(item, [typeMap.DATETIME, typeMap.DATE, typeMap.TIME])"
              v-model="formData[item.prop]"
              :default-date="formData[item.prop]"
              :mode="item.type"
              :ref="`datetimePicker${item.prop}Ref`"
              @confirm="(data) => handleDatetimeChange(item, data)"
              v-bind="relElAttrs(item)">
            </custom-datetime-picker>
          </view>
          <template v-if="item.type === 'custom'">
            <slot :name="item.prop" :item="item"></slot>
          </template>
        </up-form-item>
      </view>
      <up-line v-if="item.customLabel && item.itemAttrs && item.itemAttrs.borderBottom"></up-line>
      <slot :name="`${item.prop}Bottom`" :item="item"></slot>
    </view>
  </view>
</template>

<style lang="scss" scoped>
  .form-item-el-wrapper {
    width: 100%;
  }

  .form-item-flex {
    width: 100%;
    display: flex;
    flex-direction: row;
    justify-content: flex-start;
  }


  // 自定义表单样式,下面的样式如果无效,放入全局样式中
  .custom-form-item {
    // 自定义表单隐藏 label,用于自定义 label
    &.custom-label {

      & > .custom-form-item-container {
        // 自定义表单 label 时,flex 布局
        display: flex;
        flex-direction: row;
        box-sizing: border-box;

        & > .u-form-item {
          flex: 1;
          padding: 0;

          & > .u-form-item__body {
            & > .u-form-item__body__left {
              display: none;
            }
          }

          // form校验提示
          & > .u-form-item__body__right__message {
            margin-bottom: 5px;
          }

          & > .u-line {
            display: none;
          }
        }
      }

      // label 位置为 top 时
      &.custom-label-position-top {
        & > .custom-form-item-container {
          flex-direction: column;
        }
      }
    }

    // 最后的 formItem
    &.last-item {
      & > .custom-form-item-container {
        & > .u-form-item {
          // form校验提示
          & > .u-form-item__body__right__message {
            margin-bottom: 5px;
          }
        }
      }
    }

    .form-item-el-wrapper {
      // 文本域居右
      &.textarea-align-right {
        .u-textarea textarea {
          text-align: right;
        }
      }
    }
  }
</style>

custom-form-label.vue 文件

一般用于自定义 label 使用

<script setup>
  import { computed } from 'vue';

  const props = defineProps({
    required: Boolean,
    labelStyle: Object,
  });

  const labelStyleComputed = computed(() => {
    const style = {};
    style.justifyContent = 'flex-start';
    return Object.assign(style, props.labelStyle || {});
  });
</script>

<template>
  <view class="custom-form-label">
    <text v-if="required" class="required u-form-item__body__left__content__required">*</text>
    <text class="label u-form-item__body__left__content__label" :style="labelStyleComputed">
      <slot></slot>
    </text>
  </view>
</template>

<style scoped lang="scss">
  .custom-form-label {
    position: relative;
    display: flex;
    flex-direction: row;
    align-items: center;
    padding-right: 10rpx;

    .required {
      font-size: 20px;
      line-height: 20px;
      top: 3px;
    }

    .label {
      display: flex;
      flex-direction: row;
      align-items: center;
      flex: 1;
      font-size: 15px;
    }
  }
</style>

custom-icon-input.vue 文件

一般表单箭头输入框使用

<script setup>
  import { ref, watch } from 'vue';

  const props = defineProps({
    // 绑定值
    modelValue: {
      type: [String, Number, Boolean],
      default: '',
    },
    // 后置图标
    suffixIcon: {
      type: String,
      default: 'arrow-right',
    },
    // 后置图片
    suffixImg: {
      type: String,
    },
    // 隐藏后置插槽
    hideSuffixSlot: Boolean,
    // 输入框为空时的占位符
    placeholder: String,
    // 格式化显示内容
    formatter: Function,
    // 只读
    readonly: {
      type: Boolean,
      default: true,
    },
    // 自定义样式
    customStyle: Object,
    // 禁用
    disabled: Boolean,
  });

  const inputValue = ref(undefined);
  const emits = defineEmits(['update:modelValue', 'click']);

  watch(() => props.modelValue, () => {
    if (props.formatter) {
      inputValue.value = props.formatter(props.modelValue);
      return;
    }
    inputValue.value = props.modelValue;
  }, {
    immediate: true,
  });

  watch(() => inputValue.value, (value) => {
    if (props.formatter || props.readonly) return;
    emits('update:modelValue', value);
  });

  function handleClick() {
    if (props.disabled) return;
    emits('click');
  }
</script>

<template>
  <view
    class="custom-icon-input"
    :class="{disabled: disabled}"
    @click="handleClick">
    <up-input
      v-model="inputValue"
      :readonly="readonly"
      :placeholder="placeholder"
      :customStyle="customStyle"
      :disabled="disabled"
      disabled-color="transparent"
      v-bind="$attrs">
      <template #suffix>
        <slot v-if="!hideSuffixSlot" name="suffix"></slot>
        <up-icon v-if="suffixIcon" :name="suffixIcon" size="24rpx" color="#999"></up-icon>
        <view v-if="suffixImg" class="flex-center u-flex-center">
          <image
            style="width: 44.0rpx; height: 44.0rpx"
            :src="suffixImg">
          </image>
        </view>
      </template>
    </up-input>
  </view>
</template>

<style scoped lang="scss">
  .custom-icon-input {
    width: 100%;
  }
</style>

custom-region-picker.vue 文件

自定义区域选择器,仅提供示范,获取区域、弹窗自行完善修改

<script setup>
  import CustomPopup from '@/components/custom-popup.vue';
  import { computed, ref, watch } from 'vue';
  import { useAreaStore } from '@/sheep/store/area';
  import CustomCommonFixedBottom from '@/components/custom-common-fixed-bottom.vue';

  const { getArea } = useAreaStore();

  const props = defineProps({
    modelValue: Array,
    title: {
      type: String,
      default: '选择区域',
    },
  });

  const popupRef = ref(null);
  const areaData = ref([]);
  const currentIndex = ref([0, 0, 0]);
  // 列是否还在滑动中,微信小程序如果在滑动中就点确定,结果可能不准确
  const moving = ref(false);
  const show = ref(false);
  const emits = defineEmits(['confirm', 'cancel', 'change']);

  const provinceList = computed(() => {
    if (!areaData.value) return [];
    return areaData.value;
  });

  const cityList = computed(() => {
    if (provinceList.value.length === 0) return [];
    const item = provinceList.value[currentIndex.value[0]];
    if (!item) return [];
    return item.children || [];
  });
  const districtList = computed(() => {
    if (cityList.value.length === 0) return [];
    const item = cityList.value[currentIndex.value[1]];
    return item.children || [];
  });

  watch(() => props.modelValue, () => {
    getCurrentIndexByValue();
  }, {
    immediate: true,
    deep: true,
  });

  watch(() => areaData.value, () => {
    getCurrentIndexByValue();
  });

  function getCurrentIndexByValue() {
    if (props.modelValue) {
      if (props.modelValue[0]) {
        const index = provinceList.value.findIndex(item => item.name === props.modelValue[0]);
        currentIndex.value[0] = index === -1 ? 0 : index;
      }
      if (props.modelValue[1]) {
        const index = cityList.value.findIndex(item => item.name === props.modelValue[1]);
        currentIndex.value[1] = index === -1 ? 0 : index;
      }
      if (props.modelValue[2]) {
        const index = districtList.value.findIndex(item => item.name === props.modelValue[2]);
        currentIndex.value[2] = index === -1 ? 0 : index;
      }
    }
  }

  getArea().then((res) => {
    areaData.value = res;
  });

  // 标识滑动开始,只有微信小程序才有这样的事件
  const pickstart = () => {
    // #ifdef MP-WEIXIN
    moving.value = true;
    // #endif
  };

  // 标识滑动结束
  const pickend = () => {
    // #ifdef MP-WEIXIN
    moving.value = false;
    // #endif
  };

  const onCancel = () => {
    emits('cancel');
    show.value = false;
  };

  // 用户更改picker的列选项
  const change = (e) => {
    if (
      currentIndex.value[0] === e.detail.value[0] &&
      currentIndex.value[1] === e.detail.value[1]
    ) {
      // 不更改省市区列表
      currentIndex.value[2] = e.detail.value[2];
      return;
    } else {
      // 更改省市区列表
      if (currentIndex.value[0] !== e.detail.value[0]) {
        e.detail.value[1] = 0;
      }
      e.detail.value[2] = 0;
      currentIndex.value = e.detail.value;
    }
    emits('change', currentIndex.value);
  };

  // 用户点击确定按钮
  const onConfirm = (event = null) => {
    // #ifdef MP-WEIXIN
    if (moving.value) return;
    // #endif
    let index = currentIndex.value;
    let province = provinceList.value[index[0]];
    let city = cityList.value[index[1]];
    let district = districtList.value[index[2]];
    let result = {
      province_name: province.name,
      province_id: province.id,
      city_name: city.name,
      city_id: city.id,
      district_name: district.name,
      district_id: district.id,
    };

    if (event) emits(event, result);
  };

  const getSizeByNameLength = (name) => {
    let length = name.length;
    if (length <= 7) return '';
    if (length < 9) {
      return 'font-size:28rpx';
    } else {
      return 'font-size: 24rpx';
    }
  };

  function open() {
    popupRef.value.open();
    show.value = true;
  }

  function close() {
    popupRef.value.close();
    show.value = false;
  }

  defineExpose({
    open,
    close,
  });
</script>

<template>
  <custom-popup
    ref="popupRef"
    :title="title"
    bg-color="#fff"
    @close="onCancel"
    height="auto"
    :scroll-view="false">
    <view class="region-container">
      <view class="picker-container">
        <picker-view
          v-if="provinceList.length && cityList.length && districtList.length"
          :value="currentIndex"
          @change="change"
          class="ui-picker-view"
          @pickstart="pickstart"
          @pickend="pickend"
          indicator-style="height: 68rpx"
        >
          <picker-view-column>
            <view class="ui-column-item" v-for="province in provinceList" :key="province.id">
              <view :style="getSizeByNameLength(province.name)">{{ province.name }}</view>
            </view>
          </picker-view-column>
          <picker-view-column>
            <view class="ui-column-item" v-for="city in cityList" :key="city.id">
              <view :style="getSizeByNameLength(city.name)">{{ city.name }}</view>
            </view>
          </picker-view-column>
          <picker-view-column>
            <view class="ui-column-item" v-for="district in districtList" :key="district.id">
              <view :style="getSizeByNameLength(district.name)">{{ district.name }}</view>
            </view>
          </picker-view-column>
        </picker-view>
      </view>
    </view>
    <template #bottom>
      <custom-common-fixed-bottom :fixed="false" :safe-height="false">
        <u-button type="primary" @click="onConfirm('confirm')">确认</u-button>
      </custom-common-fixed-bottom>
    </template>
  </custom-popup>
</template>

<style scoped lang="scss">
  .region-container {
    height: 500rpx;
    display: flex;
    flex-direction: column;

    .picker-container {
      flex: 1;
    }

    .ui-picker-view {
      height: 100%;
      box-sizing: border-box;
    }

    .ui-column-item {
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 32rpx;
      color: #333;
      padding: 0 8rpx;
    }
  }
</style>

custom-datetime-picker.vue 文件

自定义日期选择器,更适合form表单

<script setup>
  import { ref, watch } from 'vue';
  import { onReady } from '@dcloudio/uni-app';
  import dayjs from 'dayjs';

  const props = defineProps({
    modelValue: [String, Number],
    placeholder: {
      type: String,
      default: '请选择日期',
    },
    mode: {
      type: String,
      default: 'date',
    },
    inputAlign: {
      type: String,
      default: 'left',
    },
    formatter: Function,
    defaultDate: [String, Number],
    minDate: {
      type: Number,
      default: 0,
    },
    maxDate: Number,
    dateFormatter: {
      type: Function,
      default: function(type, value) {
        if (type === 'year') {
          return `${value}`;
        }
        if (type === 'month') {
          return `${value}`;
        }
        if (type === 'day') {
          return `${value}`;
        }
        if (type === 'hour') {
          return `${value}`;
        }
        if (type === 'minute') {
          return `${value}`;
        }
        return value;
      },
    },
  });

  const emits = defineEmits(['update:modelValue', 'confirm', 'cancel']);
  const datetimePickerRef = ref(null);
  const datetime = ref(undefined);
  const show = ref(false);
  const inputValue = ref('');

  watch(() => props.defaultDate, () => {
    if (props.defaultDate) {
      datetime.value = dayjs(props.defaultDate).valueOf();
    }
  }, {
    immediate: true,
  });

  watch(() => props.modelValue, () => {
    if (props.formatter) {
      inputValue.value = props.formatter(props.modelValue);
      return;
    }
    inputValue.value = props.modelValue;
  }, {
    immediate: true,
  });

  function handleConfirm(data) {
    emits('update:modelValue', data.value);
    emits('confirm', data);
  }

  function handleCancel() {
    show.value = false;
    emits('cancel');
  }

  function setFormatter(fun) {
    datetimePickerRef.value.setFormatter(fun);
  }

  function close() {
    show.value = false;
  }

  onReady(() => {
    // 为了兼容微信小程序
    datetimePickerRef.value.setFormatter((type, value) => {
      if (type === 'year') {
        return `${value}`;
      }
      if (type === 'month') {
        return `${value}`;
      }
      if (type === 'day') {
        return `${value}`;
      }
      if (type === 'hour') {
        return `${value}`;
      }
      if (type === 'minute') {
        return `${value}`;
      }
      return value;
    });
  });

  defineExpose({
    // 兼容微信小程序抛出设置格式化方法
    setFormatter,
    close,
  });
</script>

<template>
  <view class="custom-datetime-picker" @click="show = true">
    <u--input
      border="none"
      v-model="inputValue"
      :placeholder="placeholder"
      :inputAlign="inputAlign"
      readonly>
      <template #suffix>
        <view class="datetime-icon">
          <image
            style="width: 44.0rpx; height: 44.0rpx"
            :src="'/static/images/icon-' + (mode === 'time' ? 'time' : 'calendar') + '.png'">
          </image>
        </view>
      </template>
    </u--input>
    <u-datetime-picker
      ref="datetimePickerRef"
      v-model="datetime"
      :show="show"
      :maxDate="maxDate"
      :minDate="minDate"
      :mode="mode"
      @cancel="handleCancel"
      @confirm="handleConfirm"
      :formatter="dateFormatter"
      v-bind="$attrs"
    ></u-datetime-picker>
  </view>
</template>

<style scoped lang="scss">
  .custom-datetime-picker {
    width: 100%;
  }

  .datetime-icon {
    height: 100%;
    display: flex;
    align-items: center;
  }
</style>

属性

CustomFormItem属性
参数说明类型默认值可选值
modelValue-Object--
items-Array--
useForm-items属性
参数说明类型默认值可选值
label表单标签,通过该属性值自动拼装 placeholder 与校验提示语String--
prop表单属性,用于双向绑定及表单验证String--
required必填,会自动添加红星,自动拼装提示语Booleanfalsetrue
type表单项类型,内置默认的一些类型,不满足的可通过设置 custom 类型自定义内容,插槽名称为 prop 属性值Stringinputcustom:自定义(通过prop插槽自定义内容)、choose:选择(只显示选择样式)、date:日期、datetime:日期时间、time:时间、area:区域选择
customLabel自定义 label 内容,可通过 prop + Label 插槽自定义内容,如果只是想添加额外元素而保留原 label 样式可使用 custom-form-label 组件Booleanfalse-
itemAttrsformItem 组件的属性,具体见 uview 的 u-form-item 属性Object--
elAttrs表单项内组件的属性,具体依不同类型组件而异Object--
rule表单项的验证,当内容为对象时,会根据 config 的 rule 属性即 item 的 required 属性生成的验证自动合并,优先级:required < config < item,内容为数组时则直接以 item 的 rule 为验证规则Object|Object[]--
showRender是否显示的渲染函数,参数:formData表单数据、item信息,返回 true 或 false 来控制当前项是否显示Function--
useForm-config属性
参数说明类型默认值可选值
itemAttrs全局的 formItem 属性Object--
elAttrs全局的表单项内组件的属性Object--
rule全局的表单验证规则,优先级小于 item 的 rule 大于 required 生成的规则Object--
labelSuffixlabel 文本追加的内容---

回调时间可通过 elAttrs 中定义on事件函数实现,如onClick,或者使用全局回调事件

CustomFormItem插槽

其中 {prop} 为 useForm 中 items 的 prop 值

名称说明
{prop}Top当前项上方插槽
{prop}Bottom当前项下方插槽
{prop}Label自定义 label 插槽,需通过 customLabel 属性开启
{prop}ChooseSuffixchoose 组件的后置内容,type=choose 时有效
{prop}表单项内容,type=custom 时有效
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

会功夫的李白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值