环境: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 | 必填,会自动添加红星,自动拼装提示语 | 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 时有效 |