环境: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--formref="formRef":model="formData":rules="form.formRules"labelWidth="auto":labelStyle="{marginLeft: '4px'}"><custom-card padding="0 14px"><custom-form-itemv-model="formData":items="form.itemList[0]"@area-change="handleAreaChane"></custom-form-item></custom-card><custom-card padding="0 14px"><custom-form-itemv-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;}// 解析是否显示formItemfunction relItemShow(item) {if (!item) return false;if (item.showRender && typeof item.showRender === 'function') {return item.showRender(formData.value, item);}return true;}// 解析formItem的classfunction 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中组件容器的classfunction 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"><viewclass="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"><viewv-if="item.type !== 'custom'"class="form-item-el-wrapper":class="[item.elWrapperClassList]"><up-inputv-if="relShow(item, typeMap.INPUT)"v-model="formData[item.prop]"v-bind="relElAttrs(item)"></up-input><up-inputv-if="relShow(item, typeMap.INPUT_NUMBER)"v-model.number="formData[item.prop]"v-bind="relElAttrs(item)"></up-input><up-textareav-if="relShow(item, typeMap.TEXTAREA)"v-model="formData[item.prop]"v-bind="relElAttrs(item)"></up-textarea><viewclass="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-inputv-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-pickerv-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><viewclass="custom-icon-input":class="{disabled: disabled}"@click="handleClick"><up-inputv-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"><imagestyle="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-WEIXINmoving.value = true;// #endif};// 标识滑动结束const pickend = () => {// #ifdef MP-WEIXINmoving.value = false;// #endif};const onCancel = () => {emits('cancel');show.value = false;};// 用户更改picker的列选项const change = (e) => {if (currentIndex.value[0] === e.detail.value[0] &¤tIndex.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-WEIXINif (moving.value) return;// #endiflet 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-popupref="popupRef":title="title"bg-color="#fff"@close="onCancel"height="auto":scroll-view="false"><view class="region-container"><view class="picker-container"><picker-viewv-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--inputborder="none"v-model="inputValue":placeholder="placeholder":inputAlign="inputAlign"readonly><template #suffix><view class="datetime-icon"><imagestyle="width: 44.0rpx; height: 44.0rpx":src="'/static/images/icon-' + (mode === 'time' ? 'time' : 'calendar') + '.png'"></image></view></template></u--input><u-datetime-pickerref="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 | 必填,会自动添加红星,自动拼装提示语 | Boolean | false | true |
type | 表单项类型,内置默认的一些类型,不满足的可通过设置 custom 类型自定义内容,插槽名称为 prop 属性值 | String | input | custom:自定义(通过prop插槽自定义内容)、choose:选择(只显示选择样式)、date:日期、datetime:日期时间、time:时间、area:区域选择 |
customLabel | 自定义 label 内容,可通过 prop + Label 插槽自定义内容,如果只是想添加额外元素而保留原 label 样式可使用 custom-form-label 组件 | Boolean | false | - |
itemAttrs | formItem 组件的属性,具体见 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 | - | - |
labelSuffix | label 文本追加的内容 | - | - | - |
回调时间可通过 elAttrs 中定义on事件函数实现,如onClick,或者使用全局回调事件
CustomFormItem插槽
其中 {prop} 为 useForm 中 items 的 prop 值
名称 | 说明 |
---|---|
{prop}Top | 当前项上方插槽 |
{prop}Bottom | 当前项下方插槽 |
{prop}Label | 自定义 label 插槽,需通过 customLabel 属性开启 |
{prop}ChooseSuffix | choose 组件的后置内容,type=choose 时有效 |
{prop} | 表单项内容,type=custom 时有效 |