添加 cl-form 组件

This commit is contained in:
icssoa
2025-08-06 10:18:17 +08:00
parent 49a57673ed
commit 42fd445248
25 changed files with 1008 additions and 52 deletions

View File

@@ -9,7 +9,7 @@
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@tap="open"
@open="open"
@clear="clear"
></cl-select-trigger>

View File

@@ -55,7 +55,7 @@ const props = defineProps({
});
// 获取父组件实例
const parent = useParent<ClRowComponentPublicInstance>();
const parent = useParent<ClRowComponentPublicInstance>("cl-row");
// 透传类型定义
type PassThrough = {
@@ -67,7 +67,7 @@ type PassThrough = {
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 计算列的padding,用于实现栅格间隔
const padding = computed(() => parseRpx(parent.gutter / 2));
const padding = computed(() => (parent == null ? "0" : parseRpx(parent.gutter / 2)));
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,223 @@
<template>
<view class="cl-form-item" :class="[pt.className]">
<view class="cl-form-item__inner" :class="[`is-${labelPosition}`, pt.inner?.className]">
<view
class="cl-form-item__label"
:class="[`is-${labelPosition}`, pt.label?.className]"
:style="{
width: labelPosition != 'top' ? labelWidth : 'auto'
}"
v-if="label != ''"
>
<cl-text>{{ label }}</cl-text>
<cl-text
color="error"
:pt="{
className: 'ml-1'
}"
v-if="showAsterisk"
>
*
</cl-text>
</view>
<view class="cl-form-item__content" :class="[pt.content?.className]">
<slot></slot>
</view>
</view>
<slot name="error" :error="errorText" v-if="hasError && showMessage">
<cl-text
color="error"
:pt="{
className: parseClass(['mt-2 !text-sm', pt.error?.className])
}"
>
{{ errorText }}
</cl-text>
</slot>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch, type PropType } from "vue";
import { parseClass, parsePt } from "@/cool";
import type { ClFormLabelPosition, PassThroughProps } from "../../types";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-form-item"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 字段标签
label: {
type: String,
default: ""
},
// 字段名称
prop: {
type: String,
default: ""
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: null
},
// 标签宽度
labelWidth: {
type: String as PropType<string | null>,
default: null
},
// 是否显示必填星号
showAsterisk: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否显示错误信息
showMessage: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否必填
required: {
type: Boolean,
default: false
}
});
// cl-form 上下文
const { formRef, getError, getValue, validateField, addField, removeField } = useForm();
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
error?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前错误信息
const errorText = computed<string>(() => {
return getError(props.prop);
});
// 是否有错误
const hasError = computed<boolean>(() => {
return errorText.value != "";
});
// 当前标签位置
const labelPosition = computed<ClFormLabelPosition>(() => {
return props.labelPosition ?? formRef.value?.labelPosition ?? "left";
});
// 标签宽度
const labelWidth = computed<string>(() => {
return props.labelWidth ?? formRef.value?.labelWidth ?? "120rpx";
});
// 是否显示必填星号
const showAsterisk = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showAsterisk ?? formRef.value?.showAsterisk ?? true;
});
// 是否显示错误信息
const showMessage = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showMessage ?? formRef.value?.showMessage ?? true;
});
watch(
computed(() => props.required),
(val: boolean) => {
if (val) {
addField(props.prop);
} else {
removeField(props.prop);
}
},
{
immediate: true
}
);
onMounted(() => {
watch(
computed(() => getValue(props.prop)!),
() => {
if (props.required) {
validateField(props.prop);
}
},
{
deep: true
}
);
});
onUnmounted(() => {
removeField(props.prop);
});
</script>
<style lang="scss" scoped>
.cl-form-item {
@apply w-full mb-6;
&__inner {
@apply w-full;
&.is-top {
@apply flex flex-col;
}
&.is-left {
@apply flex flex-row;
}
&.is-right {
@apply flex flex-row;
}
}
&__label {
@apply flex flex-row items-center;
&.is-top {
@apply w-full mb-2;
}
&.is-left {
@apply mr-3;
}
&.is-right {
@apply mr-3 justify-end;
}
}
&__content {
@apply relative flex-1 w-full;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import type { ClFormItemPassThrough } from "./props";
import type { ClFormRule } from "../cl-form/props";
export type ClFormItemProps = {
className?: string;
pt?: ClFormItemPassThrough;
label?: string;
prop?: string;
required?: boolean;
labelPosition?: "left" | "top" | "right";
labelWidth?: string;
rules?: ClFormRule | ClFormRule[];
showRequiredAsterisk?: boolean;
error?: string;
disabled?: boolean;
};

View File

@@ -0,0 +1,302 @@
<template>
<view
class="cl-form"
:class="[
`cl-form--label-${labelPosition}`,
{
'cl-form--disabled': disabled
},
pt.className
]"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch, type PropType } from "vue";
import { isEmpty, parsePt, parseToObject } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
import { $t, t } from "@/locale";
defineOptions({
name: "cl-form"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 表单数据模型
modelValue: {
type: Object as PropType<any>,
default: () => ({})
},
// 表单规则
rules: {
type: Object as PropType<Map<string, ClFormRule[]>>,
default: () => new Map<string, ClFormRule[]>()
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: "top"
},
// 标签宽度
labelWidth: {
type: String,
default: "140rpx"
},
// 是否显示必填星号
showAsterisk: {
type: Boolean,
default: true
},
// 是否显示错误信息
showMessage: {
type: Boolean,
default: true
},
// 是否禁用整个表单
disabled: {
type: Boolean,
default: false
}
});
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 表单数据
const data = ref({} as UTSJSONObject);
// 表单字段错误信息
const errors = ref(new Map<string, string>());
// 表单字段集合
const fields = ref(new Set<string>([]));
// 标签位置
const labelPosition = computed(() => props.labelPosition);
// 标签宽度
const labelWidth = computed(() => props.labelWidth);
// 是否显示必填星号
const showAsterisk = computed(() => props.showAsterisk);
// 是否显示错误信息
const showMessage = computed(() => props.showMessage);
// 是否禁用整个表单
const disabled = computed(() => props.disabled);
// 设置字段错误信息
function setError(prop: string, error: string) {
if (prop != "") {
errors.value.set(prop, error);
}
}
// 移除字段错误信息
function removeError(prop: string) {
if (prop != "") {
errors.value.delete(prop);
}
}
// 获取字段错误信息
function getError(prop: string): string {
if (prop != "") {
return errors.value.get(prop) ?? "";
}
return "";
}
// 清除所有错误信息
function clearErrors() {
errors.value.clear();
}
// 获取字段值
function getValue(prop: string): any | null {
if (prop != "") {
return data.value[prop];
}
return null;
}
// 注册表单字段
function addField(prop: string) {
if (prop != "") {
fields.value.add(prop);
}
}
// 注销表单字段
function removeField(prop: string) {
if (prop != "") {
fields.value.delete(prop);
removeError(prop);
}
}
// 获取字段规则
function getRule(prop: string): ClFormRule[] {
return props.rules.get(prop) ?? ([] as ClFormRule[]);
}
// 验证单个规则
function validateRule(value: any | null, rule: ClFormRule): null | string {
// 必填验证
if (rule.required == true) {
if (value == null || value == "" || (Array.isArray(value) && value.length == 0)) {
return rule.message ?? t("此字段为必填项");
}
}
// 如果值为空且不是必填,直接通过
if ((value == null || value == "") && rule.required != true) {
return null;
}
// 最小长度验证
if (rule.min != null) {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len < rule.min) {
return rule.message ?? $t(`最少需要{min}个字符`, { min: rule.min });
}
}
// 最大长度验证
if (rule.max != null) {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len > rule.max) {
return rule.message ?? $t(`最多允许{max}个字符`, { max: rule.max });
}
}
// 正则验证
if (rule.pattern != null) {
if (!rule.pattern.test(`${value}`)) {
return rule.message ?? t("格式不正确");
}
}
// 自定义验证
if (rule.validator != null) {
const result = rule.validator(value);
if (result != true) {
return typeof result == "string" ? result : (rule.message ?? t("验证失败"));
}
}
return null;
}
// 清除所有验证
function clearValidate() {
nextTick(() => {
clearErrors();
});
}
// 验证单个字段
function validateField(prop: string): string | null {
let error = null as string | null;
if (prop != "") {
const value = getValue(prop);
const rules = getRule(prop);
if (!isEmpty(rules)) {
// 逐个验证规则
rules.find((rule) => {
const msg = validateRule(value, rule);
if (msg != null) {
error = msg;
return true;
}
return false;
});
}
removeError(prop);
}
if (error != null) {
setError(prop, error!);
}
return error;
}
// 验证整个表单
function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) {
const errs = [] as ClFormValidateError[];
fields.value.forEach((prop) => {
const result = validateField(prop);
if (result != null) {
errs.push({
field: prop,
message: result
});
}
});
callback(errs.length == 0, errs);
}
watch(
computed(() => parseToObject(props.modelValue)),
(val: UTSJSONObject) => {
data.value = val;
},
{
immediate: true,
deep: true
}
);
defineExpose({
labelPosition,
labelWidth,
showAsterisk,
showMessage,
disabled,
data,
errors,
fields,
addField,
removeField,
getValue,
setError,
getError,
removeError,
clearErrors,
getRule,
validateRule,
clearValidate,
validateField,
validate
});
</script>
<style lang="scss" scoped>
.cl-form {
@apply w-full;
}
</style>

View File

@@ -0,0 +1,12 @@
import type { ClFormData, ClFormRule, ClFormValidateResult, ClFormPassThrough } from "./props";
export type ClFormProps = {
className?: string;
pt?: ClFormPassThrough;
modelValue?: ClFormData;
rules?: Record<string, ClFormRule | ClFormRule[]>;
labelPosition?: "left" | "top" | "right";
labelWidth?: string;
showRequiredAsterisk?: boolean;
disabled?: boolean;
};

View File

@@ -3,7 +3,7 @@
class="cl-input-number"
:class="[
{
'cl-input-number--disabled': disabled
'cl-input-number--disabled': isDisabled
},
pt.className
]"
@@ -41,7 +41,7 @@
<cl-input
:model-value="`${value}`"
:type="inputType"
:disabled="disabled"
:disabled="isDisabled"
:clearable="false"
:readonly="inputable == false"
:placeholder="placeholder"
@@ -92,6 +92,7 @@ import { computed, nextTick, ref, watch, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import { useLongPress, parsePt, parseRpx } from "@/cool";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-input-number"
@@ -156,6 +157,14 @@ const emit = defineEmits(["update:modelValue", "change"]);
// 长按操作
const longPress = useLongPress();
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 数值样式
type ValuePassThrough = {
className?: string;
@@ -184,10 +193,10 @@ const pt = computed(() => parsePt<PassThrough>(props.pt));
const value = ref(props.modelValue);
// 是否可以继续增加数值
const isPlus = computed(() => !props.disabled && value.value < props.max);
const isPlus = computed(() => !isDisabled.value && value.value < props.max);
// 是否可以继续减少数值
const isMinus = computed(() => !props.disabled && value.value > props.min);
const isMinus = computed(() => !isDisabled.value && value.value > props.min);
/**
* 更新数值并触发事件
@@ -232,7 +241,7 @@ function update() {
* 在非禁用状态下增加step值
*/
function onPlus() {
if (props.disabled || !isPlus.value) return;
if (isDisabled.value || !isPlus.value) return;
longPress.start(() => {
if (isPlus.value) {
@@ -248,7 +257,7 @@ function onPlus() {
* 在非禁用状态下减少step值
*/
function onMinus() {
if (props.disabled || !isMinus.value) return;
if (isDisabled.value || !isMinus.value) return;
longPress.start(() => {
if (isMinus.value) {

View File

@@ -7,7 +7,7 @@
'is-dark': isDark,
'cl-input--border': border,
'cl-input--focus': isFocus,
'cl-input--disabled': disabled
'cl-input--disabled': isDisabled
}
]"
@tap="onTap"
@@ -26,13 +26,13 @@
class="cl-input__inner"
:class="[
{
'is-disabled': disabled,
'is-disabled': isDisabled,
'is-dark': isDark
},
pt.inner?.className
]"
:value="value"
:disabled="readonly ?? disabled"
:disabled="readonly ?? isDisabled"
:type="type"
:password="isPassword"
:focus="isFocus"
@@ -80,11 +80,12 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch, type PropType } from "vue";
import { computed, nextTick, onMounted, ref, watch, type PropType } from "vue";
import type { ClInputType, PassThroughProps } from "../../types";
import { isDark, parseClass, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { t } from "@/locale";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-input"
@@ -201,6 +202,14 @@ const emit = defineEmits([
"keyboardheightchange"
]);
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传样式类型定义
type PassThrough = {
className?: string;
@@ -271,7 +280,7 @@ function onKeyboardheightchange(e: UniInputKeyboardHeightChangeEvent) {
// 点击事件
function onTap() {
if (props.disabled) {
if (isDisabled.value) {
return;
}

View File

@@ -10,7 +10,7 @@
:focus="popupRef?.isOpen"
:text="text"
arrow-icon="calendar-line"
@tap="open()"
@open="open()"
@clear="clear"
></cl-select-trigger>

View File

@@ -10,7 +10,7 @@
:focus="popupRef?.isOpen"
:text="text"
arrow-icon="time-line"
@tap="open()"
@open="open()"
@clear="clear"
></cl-select-trigger>

View File

@@ -4,11 +4,12 @@
:class="[
{
'is-dark': isDark,
'cl-select-trigger--disabled': disabled,
'cl-select-trigger--disabled': isDisabled,
'cl-select-trigger--focus': focus
},
pt.className
]"
@tap="open"
>
<view class="cl-select-trigger__content">
<cl-text
@@ -16,7 +17,7 @@
:pt="{
className: parseClass([
{
'!text-surface-400': disabled
'!text-surface-400': isDisabled
},
pt.text?.className
])
@@ -35,7 +36,7 @@
</text>
</view>
<view v-if="showText && !disabled" class="cl-select-trigger__icon" @tap.stop="clear">
<view v-if="showText && !isDisabled" class="cl-select-trigger__icon" @tap.stop="clear">
<cl-icon
name="close-circle-fill"
:size="32"
@@ -43,7 +44,7 @@
></cl-icon>
</view>
<view v-if="!disabled && !showText" class="cl-select-trigger__icon">
<view v-if="!isDisabled && !showText" class="cl-select-trigger__icon">
<cl-icon
:name="pt.icon?.name ?? arrowIcon"
:size="pt.icon?.size ?? 32"
@@ -61,6 +62,7 @@ import type { ClIconProps } from "../cl-icon/props";
import { isDark, parseClass, parsePt } from "@/cool";
import { t } from "@/locale";
import type { PassThroughProps } from "../../types";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-select-trigger"
@@ -100,7 +102,14 @@ const props = defineProps({
}
});
const emit = defineEmits(["clear"]);
const emit = defineEmits(["open", "clear"]);
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传样式类型定义
type PassThrough = {
@@ -120,6 +129,15 @@ const showText = computed(() => props.text != "");
function clear() {
emit("clear");
}
// 打开选择器
function open() {
if (isDisabled.value) {
return;
}
emit("open");
}
</script>
<style lang="scss" scoped>

View File

@@ -9,7 +9,7 @@
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@tap="open()"
@open="open()"
@clear="clear"
></cl-select-trigger>
@@ -68,6 +68,7 @@ import { isEmpty, parsePt } from "@/cool";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import { t } from "@/locale";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-select"
@@ -268,10 +269,6 @@ let callback: ((value: Value) => void) | null = null;
// 打开选择器
function open(cb: ((value: Value) => void) | null = null) {
if (props.disabled) {
return;
}
visible.value = true;
callback = cb;
}

View File

@@ -7,7 +7,7 @@
'is-dark': isDark,
'cl-textarea--border': border,
'cl-textarea--focus': isFocus,
'cl-textarea--disabled': disabled
'cl-textarea--disabled': isDisabled
}
]"
@tap="onTap"
@@ -16,7 +16,7 @@
class="cl-textarea__inner"
:class="[
{
'is-disabled': disabled,
'is-disabled': isDisabled,
'is-dark': isDark
},
pt.inner?.className
@@ -26,7 +26,7 @@
}"
:value="value"
:name="name"
:disabled="readonly ?? disabled"
:disabled="readonly ?? isDisabled"
:placeholder="placeholder"
:placeholder-class="`text-surface-400 ${placeholderClass}`"
:maxlength="maxlength"
@@ -63,6 +63,7 @@ import { parsePt, parseRpx } from "@/cool";
import type { PassThroughProps } from "../../types";
import { isDark } from "@/cool";
import { t } from "@/locale";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-textarea"
@@ -221,6 +222,14 @@ const emit = defineEmits([
"keyboardheightchange"
]);
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传样式类型定义
type PassThrough = {
className?: string;
@@ -277,7 +286,7 @@ function onLineChange(e: UniTextareaLineChangeEvent) {
// 点击事件
function onTap() {
if (props.disabled) {
if (isDisabled.value) {
return;
}

View File

@@ -7,7 +7,7 @@
:class="[
{
'is-dark': isDark,
'is-disabled': disabled
'is-disabled': isDisabled
},
pt.item?.className
]"
@@ -33,7 +33,7 @@
className: 'cl-upload__close'
}"
@tap.stop="remove(item.uid)"
v-if="!disabled"
v-if="!isDisabled"
></cl-icon>
<view class="cl-upload__progress" v-if="item.progress < 100">
@@ -47,7 +47,7 @@
:class="[
{
'is-dark': isDark,
'is-disabled': disabled
'is-disabled': isDisabled
},
pt.add?.className
]"
@@ -84,6 +84,7 @@ import { forInObject, isDark, parseClass, parsePt, parseRpx, uploadFile, uuid }
import { t } from "@/locale";
import { computed, reactive, ref, watch, type PropType } from "vue";
import type { ClUploadItem, PassThroughProps } from "../../types";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-upload"
@@ -156,6 +157,14 @@ const emit = defineEmits([
"progress" // 上传进度更新时触发
]);
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传属性的类型定义
type PassThrough = {
className?: string;
@@ -179,7 +188,7 @@ const activeIndex = ref(0);
const isAdd = computed(() => {
const n = list.value.length;
if (props.disabled) {
if (isDisabled.value) {
// 禁用状态下,只有没有文件时才显示添加按钮
return n == 0;
} else {
@@ -294,7 +303,7 @@ function remove(uid: string) {
* @param {number} index - 操作的索引,-1表示新增其他表示替换
*/
function choose(index: number) {
if (props.disabled) {
if (isDisabled.value) {
return;
}