瀏覽代碼

添加 cl-form 组件

icssoa 7 月之前
父節點
當前提交
42fd445248

+ 16 - 16
cool/hooks/parent.ts

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

+ 1 - 1
cool/utils/comm.ts

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

+ 16 - 0
cool/utils/parse.ts

@@ -1,3 +1,4 @@
+import { ref, type Ref } from "vue";
 import { forEach, forInObject, isArray, isObject, isString } from "./comm";
 
 /**
@@ -105,6 +106,21 @@ export const parseClass = (data: any): string => {
 };
 
 /**
+ * 将自定义类型数据转换为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 要转换的值,可以是数字或字符串
  * @returns 转换后的rpx单位字符串

+ 6 - 0
pages.json

@@ -113,6 +113,12 @@
 					}
 				},
 				{
+					"path": "form/form",
+					"style": {
+						"navigationBarTitleText": "Form 表单验证"
+					}
+				},
+				{
 					"path": "form/input",
 					"style": {
 						"navigationBarTitleText": "Input 输入框"

+ 175 - 0
pages/demo/form/form.uvue

@@ -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>

+ 5 - 0
pages/index/home.uvue

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

+ 1 - 1
uni_modules/cool-ui/components/cl-cascader/cl-cascader.uvue

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

+ 2 - 2
uni_modules/cool-ui/components/cl-col/cl-col.uvue

@@ -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>

+ 223 - 0
uni_modules/cool-ui/components/cl-form-item/cl-form-item.uvue

@@ -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>

+ 16 - 0
uni_modules/cool-ui/components/cl-form-item/props.ts

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

+ 302 - 0
uni_modules/cool-ui/components/cl-form/cl-form.uvue

@@ -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>

+ 12 - 0
uni_modules/cool-ui/components/cl-form/props.ts

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

+ 15 - 6
uni_modules/cool-ui/components/cl-input-number/cl-input-number.uvue

@@ -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) {

+ 14 - 5
uni_modules/cool-ui/components/cl-input/cl-input.uvue

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

+ 1 - 1
uni_modules/cool-ui/components/cl-select-date/cl-select-date.uvue

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

+ 1 - 1
uni_modules/cool-ui/components/cl-select-time/cl-select-time.uvue

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

+ 23 - 5
uni_modules/cool-ui/components/cl-select-trigger/cl-select-trigger.uvue

@@ -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>

+ 2 - 5
uni_modules/cool-ui/components/cl-select/cl-select.uvue

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

+ 13 - 4
uni_modules/cool-ui/components/cl-textarea/cl-textarea.uvue

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

+ 14 - 5
uni_modules/cool-ui/components/cl-upload/cl-upload.uvue

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

+ 86 - 0
uni_modules/cool-ui/hooks/form.ts

@@ -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();
+}

+ 1 - 0
uni_modules/cool-ui/hooks/index.ts

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

+ 4 - 0
uni_modules/cool-ui/index.d.ts

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

+ 31 - 0
uni_modules/cool-ui/types/component.d.ts

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

+ 28 - 0
uni_modules/cool-ui/types/index.ts

@@ -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";