添加 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

@@ -1,22 +1,22 @@
import { getCurrentInstance } from "vue";
/**
* 获取父组件实例
*
* 用于在子组件中获取父组件实例,以便访问父组件的属性和方法
*
* @example
* ```ts
* // 在子组件中使用
* const parent = useParent<ParentType>();
* // 访问父组件属性
* console.log(parent.someProperty);
* ```
*
* @template T 父组件实例的类型
* @returns {T} 返回父组件实例
* 获取父组件
* @param name 组件名称
* @example useParent<ClFormComponentPublicInstance>("cl-form")
* @returns 父组件
*/
export function useParent<T>(): T {
export function useParent<T>(name: string): T | null {
const { proxy } = getCurrentInstance()!;
return proxy?.$parent as T;
let p = proxy?.$parent;
while (p != null) {
if (p.$options.name == name) {
return p as T | null;
}
p = p.$parent;
}
return p as T | null;
}

View File

@@ -166,7 +166,7 @@ export function get(object: any, path: string, defaultValue: any | null = null):
* 设置对象的属性值
* @example set({a: 1}, 'b', 2) // {a: 1, b: 2}
*/
export function set(object: any, key: string, value: any): void {
export function set(object: any, key: string, value: any | null): void {
(object as UTSJSONObject)[key] = value;
}

View File

@@ -1,3 +1,4 @@
import { ref, type Ref } from "vue";
import { forEach, forInObject, isArray, isObject, isString } from "./comm";
/**
@@ -104,6 +105,21 @@ export const parseClass = (data: any): string => {
return names.join(" ");
};
/**
* 将自定义类型数据转换为UTSJSONObject对象
* @param data 要转换的数据
* @returns 转换后的UTSJSONObject对象
*/
export function parseToObject<T>(data: T): UTSJSONObject {
// #ifdef APP
return JSON.parseObject(JSON.stringify(data)!)!;
// #endif
// #ifndef APP
return JSON.parse(JSON.stringify(data)) as UTSJSONObject;
// #endif
}
/**
* 将数值或字符串转换为rpx单位的字符串
* @param val 要转换的值,可以是数字或字符串

View File

@@ -112,6 +112,12 @@
"navigationBarTitleText": "Tag 标签"
}
},
{
"path": "form/form",
"style": {
"navigationBarTitleText": "Form 表单验证"
}
},
{
"path": "form/input",
"style": {

175
pages/demo/form/form.uvue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<cl-page>
<view class="p-3">
<demo-item>
<cl-form
:pt="{
className: 'p-2 pb-0'
}"
v-model="formData"
ref="formRef"
:rules="rules"
:disabled="saving"
label-position="top"
>
<cl-form-item label="头像" prop="avatarUrl" required>
<cl-upload v-model="formData.avatarUrl"></cl-upload>
</cl-form-item>
<cl-form-item label="用户名" prop="nickName" required>
<cl-input v-model="formData.nickName" placeholder="请输入用户名"></cl-input>
</cl-form-item>
<cl-form-item label="邮箱" prop="email">
<cl-input v-model="formData.email" placeholder="请输入邮箱地址"></cl-input>
</cl-form-item>
<cl-form-item label="年龄" prop="age">
<cl-input-number
v-model="formData.age"
:min="18"
:max="50"
></cl-input-number>
</cl-form-item>
<cl-form-item label="性别" prop="gender">
<cl-select
v-model="formData.gender"
:options="options['gender']"
></cl-select>
</cl-form-item>
<cl-form-item label="个人简介" prop="description">
<cl-textarea
v-model="formData.description"
placeholder="请输入个人简介"
:maxlength="200"
></cl-textarea>
</cl-form-item>
</cl-form>
</demo-item>
<demo-item>
<cl-text :pt="{ className: '!text-sm p-2' }">{{
JSON.stringify(formData, null, 4)
}}</cl-text>
</demo-item>
</view>
<cl-footer>
<view class="flex flex-row">
<cl-button type="info" :pt="{ className: 'flex-1' }" @click="reset">重置</cl-button>
<cl-button
type="primary"
:loading="saving"
:pt="{ className: 'flex-1' }"
@click="submit"
>提交</cl-button
>
</view>
</cl-footer>
</cl-page>
</template>
<script setup lang="ts">
import { reactive, ref, type Ref } from "vue";
import DemoItem from "../components/item.uvue";
import { useForm, useUi, type ClFormRule, type ClSelectOption } from "@/uni_modules/cool-ui";
const ui = useUi();
const { formRef, validate, clearValidate } = useForm();
const options = reactive({
gender: [
{
label: "未知",
value: 0
},
{
label: "男",
value: 1
},
{
label: "女",
value: 2
}
] as ClSelectOption[]
});
type FormData = {
avatarUrl: string;
nickName: string;
email: string;
age: number;
gender: number;
description: string;
pics: string[];
};
// 表单数据
const formData = ref<FormData>({
avatarUrl: "",
nickName: "神仙都没用",
email: "",
age: 18,
gender: 0,
description: "",
pics: []
}) as Ref<FormData>;
// 表单验证规则
const rules = new Map<string, ClFormRule[]>([
["avatarUrl", [{ required: true, message: "头像不能为空" }]],
[
"nickName",
[
{ required: true, message: "用户名不能为空" },
{ min: 3, max: 20, message: "用户名长度在3-20个字符之间" }
]
],
[
"email",
[
{ required: true, message: "邮箱不能为空" },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "邮箱格式不正确" }
]
]
]);
// 是否保存中
const saving = ref(false);
function reset() {
formData.value.avatarUrl = "";
formData.value.nickName = "";
formData.value.email = "";
formData.value.age = 18;
formData.value.gender = 0;
formData.value.description = "";
formData.value.pics = [];
clearValidate();
}
function submit() {
validate((valid, errors) => {
if (valid) {
saving.value = true;
setTimeout(() => {
ui.showToast({
message: "提交成功",
icon: "check-line"
});
saving.value = false;
reset();
}, 2000);
} else {
ui.showToast({
message: errors[0].message
});
}
});
}
</script>

View File

@@ -118,6 +118,11 @@ const data = computed<Item[]>(() => {
{
label: t("表单组件"),
children: [
{
label: t("表单验证"),
icon: "draft-line",
path: "/pages/demo/form/form"
},
{
label: t("输入框"),
icon: "input-field",

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;
}

View File

@@ -0,0 +1,86 @@
import { computed, ref, type ComputedRef } from "vue";
import type { ClFormRule, ClFormValidateError } from "../types";
import { useParent } from "@/cool";
class UseForm {
public formRef = ref<ClFormComponentPublicInstance | null>(null);
public disabled: ComputedRef<boolean>;
constructor() {
// 获取 cl-form 实例
if (this.formRef.value == null) {
const ClForm = useParent<ClFormComponentPublicInstance>("cl-form");
if (ClForm != null) {
this.formRef.value = ClForm;
}
}
// 监听表单是否禁用
this.disabled = computed<boolean>(() => this.formRef.value?.disabled ?? false);
}
// 注册表单字段
addField = (prop: string): void => {
this.formRef.value!.addField(prop);
};
// 注销表单字段
removeField = (prop: string): void => {
this.formRef.value!.removeField(prop);
};
// 获取字段值
getValue = (prop: string): any | null => {
return this.formRef.value!.getValue(prop);
};
// 设置字段错误信息
setError = (prop: string, error: string): void => {
this.formRef.value!.setError(prop, error);
};
// 获取字段错误信息
getError = (prop: string): string => {
return this.formRef.value!.getError(prop);
};
// 移除字段错误信息
removeError = (prop: string): void => {
this.formRef.value!.removeError(prop);
};
// 清除所有错误信息
clearErrors = (): void => {
this.formRef.value!.clearErrors();
};
// 获取字段规则
getRule = (prop: string): ClFormRule[] => {
return this.formRef.value!.getRule(prop);
};
// 验证单个规则
validateRule = (value: any | null, rule: ClFormRule): string | null => {
return this.formRef.value!.validateRule(value, rule);
};
// 清除所有验证
clearValidate = (): void => {
this.formRef.value!.clearValidate();
};
// 验证单个字段
validateField = (prop: string): string | null => {
return this.formRef.value!.validateField(prop);
};
// 验证整个表单
validate = (callback: (valid: boolean, errors: ClFormValidateError[]) => void): void => {
this.formRef.value!.validate(callback);
};
}
export function useForm() {
return new UseForm();
}

View File

@@ -1,2 +1,3 @@
export * from "./ui";
export * from "./component";
export * from "./form";

View File

@@ -16,6 +16,8 @@ import type { ClCropperProps, ClCropperPassThrough } from "./components/cl-cropp
import type { ClDraggableProps, ClDraggablePassThrough } from "./components/cl-draggable/props";
import type { ClFloatViewProps } from "./components/cl-float-view/props";
import type { ClFooterProps, ClFooterPassThrough } from "./components/cl-footer/props";
import type { ClFormProps } from "./components/cl-form/props";
import type { ClFormItemProps } from "./components/cl-form-item/props";
import type { ClIconProps, ClIconPassThrough } from "./components/cl-icon/props";
import type { ClImageProps, ClImagePassThrough } from "./components/cl-image/props";
import type { ClIndexBarProps, ClIndexBarPassThrough } from "./components/cl-index-bar/props";
@@ -84,6 +86,8 @@ declare module "vue" {
"cl-draggable": (typeof import('./components/cl-draggable/cl-draggable.uvue')['default']) & import('vue').DefineComponent<ClDraggableProps>;
"cl-float-view": (typeof import('./components/cl-float-view/cl-float-view.uvue')['default']) & import('vue').DefineComponent<ClFloatViewProps>;
"cl-footer": (typeof import('./components/cl-footer/cl-footer.uvue')['default']) & import('vue').DefineComponent<ClFooterProps>;
"cl-form": (typeof import('./components/cl-form/cl-form.uvue')['default']) & import('vue').DefineComponent<ClFormProps>;
"cl-form-item": (typeof import('./components/cl-form-item/cl-form-item.uvue')['default']) & import('vue').DefineComponent<ClFormItemProps>;
"cl-icon": (typeof import('./components/cl-icon/cl-icon.uvue')['default']) & import('vue').DefineComponent<ClIconProps>;
"cl-image": (typeof import('./components/cl-image/cl-image.uvue')['default']) & import('vue').DefineComponent<ClImageProps>;
"cl-index-bar": (typeof import('./components/cl-index-bar/cl-index-bar.uvue')['default']) & import('vue').DefineComponent<ClIndexBarProps>;

View File

@@ -159,3 +159,34 @@ declare type ClCropperComponentPublicInstance = {
chooseImage: () => void;
toPng: () => Promise<string>;
};
declare type ClFormComponentPublicInstance = {
labelPosition: "left" | "top" | "right";
labelWidth: string;
showAsterisk: boolean;
showMessage: boolean;
disabled: boolean;
data: UTSJSONObject;
errors: Map<string, string>;
fields: Set<string>;
addField: (prop: string) => void;
removeField: (prop: string) => void;
getValue: (prop: string) => any | null;
setData: (data: UTSJSONObject) => void;
setError: (prop: string, error: string) => void;
getError: (prop: string) => string;
removeError: (prop: string) => void;
clearErrors: () => void;
getRule: (prop: string) => ClFormRule[];
validateRule: (value: any | null, rule: ClFormRule) => string | null;
clearValidate: () => void;
validateField: (prop: string) => string | null;
validate: (callback: (valid: boolean, errors: ClFormValidateError[]) => void) => void;
};
declare type ClFormItemComponentPublicInstance = {
validate: () => Promise<boolean>;
clearValidate: () => void;
hasError: boolean;
currentError: string;
};

View File

@@ -128,3 +128,31 @@ export type ClSelectDateShortcut = {
label: string;
value: string[];
};
// 表单规则类型
export type ClFormRule = {
// 是否必填
required?: boolean;
// 错误信息
message?: string;
// 最小长度
min?: number;
// 最大长度
max?: number;
// 正则验证
pattern?: RegExp;
// 自定义验证函数
validator?: (value: any | null) => boolean | string;
};
export type ClFormValidateError = {
field: string;
message: string;
};
export type ClFormValidateResult = {
valid: boolean;
errors: ClFormValidateError[];
};
export type ClFormLabelPosition = "left" | "top" | "right";