cl-form.uvue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. <template>
  2. <view
  3. class="cl-form"
  4. :class="[
  5. `cl-form--label-${labelPosition}`,
  6. {
  7. 'cl-form--disabled': disabled
  8. },
  9. pt.className
  10. ]"
  11. >
  12. <slot></slot>
  13. </view>
  14. </template>
  15. <script setup lang="ts">
  16. import { computed, getCurrentInstance, nextTick, ref, watch, type PropType } from "vue";
  17. import { get, isEmpty, isNull, isString, parsePt, parseToObject } from "@/cool";
  18. import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
  19. import { $t, t } from "@/locale";
  20. import { usePage } from "../../hooks";
  21. defineOptions({
  22. name: "cl-form"
  23. });
  24. // 组件属性定义
  25. const props = defineProps({
  26. // 透传样式
  27. pt: {
  28. type: Object,
  29. default: () => ({})
  30. },
  31. // 表单数据模型
  32. modelValue: {
  33. type: Object as PropType<any>,
  34. default: () => ({})
  35. },
  36. // 表单规则
  37. rules: {
  38. type: Object as PropType<Map<string, ClFormRule[]>>,
  39. default: () => new Map<string, ClFormRule[]>()
  40. },
  41. // 标签位置
  42. labelPosition: {
  43. type: String as PropType<ClFormLabelPosition>,
  44. default: "top"
  45. },
  46. // 标签宽度
  47. labelWidth: {
  48. type: String,
  49. default: "140rpx"
  50. },
  51. // 是否显示必填星号
  52. showAsterisk: {
  53. type: Boolean,
  54. default: true
  55. },
  56. // 是否显示错误信息
  57. showMessage: {
  58. type: Boolean,
  59. default: true
  60. },
  61. // 是否禁用整个表单
  62. disabled: {
  63. type: Boolean,
  64. default: false
  65. },
  66. // 滚动到第一个错误位置
  67. scrollToError: {
  68. type: Boolean,
  69. default: true
  70. }
  71. });
  72. const { proxy } = getCurrentInstance()!;
  73. // cl-page 上下文
  74. const page = usePage();
  75. // 透传样式类型
  76. type PassThrough = {
  77. className?: string;
  78. };
  79. // 解析透传样式
  80. const pt = computed(() => parsePt<PassThrough>(props.pt));
  81. // 表单数据
  82. const data = ref({} as UTSJSONObject);
  83. // 表单字段错误信息
  84. const errors = ref(new Map<string, string>());
  85. // 表单字段集合
  86. const fields = ref(new Set<string>([]));
  87. // 标签位置
  88. const labelPosition = computed(() => props.labelPosition);
  89. // 标签宽度
  90. const labelWidth = computed(() => props.labelWidth);
  91. // 是否显示必填星号
  92. const showAsterisk = computed(() => props.showAsterisk);
  93. // 是否显示错误信息
  94. const showMessage = computed(() => props.showMessage);
  95. // 是否禁用整个表单
  96. const disabled = computed(() => props.disabled);
  97. // 错误信息锁定
  98. const errorLock = ref(false);
  99. // 设置字段错误信息
  100. function setError(prop: string, error: string) {
  101. if (errorLock.value) {
  102. return;
  103. }
  104. if (prop != "") {
  105. errors.value.set(prop, error);
  106. }
  107. }
  108. // 移除字段错误信息
  109. function removeError(prop: string) {
  110. if (prop != "") {
  111. errors.value.delete(prop);
  112. }
  113. }
  114. // 获取字段错误信息
  115. function getError(prop: string): string {
  116. if (prop != "") {
  117. return errors.value.get(prop) ?? "";
  118. }
  119. return "";
  120. }
  121. // 获得错误信息,并滚动到第一个错误位置
  122. async function getErrors(): Promise<ClFormValidateError[]> {
  123. return new Promise((resolve) => {
  124. // 错误信息
  125. const errs = [] as ClFormValidateError[];
  126. // 错误信息位置
  127. const tops = new Map<string, number>();
  128. // 完成回调,将错误信息添加到数组中
  129. function done() {
  130. tops.forEach((top, prop) => {
  131. errs.push({
  132. field: prop,
  133. message: getError(prop)
  134. });
  135. });
  136. // 滚动到第一个错误位置
  137. if (props.scrollToError && errs.length > 0) {
  138. page.scrollTo((tops.get(errs[0].field) ?? 0) + page.getScrollTop());
  139. }
  140. resolve(errs);
  141. }
  142. // 如果错误信息为空,直接返回
  143. if (errors.value.size == 0) {
  144. done();
  145. return;
  146. }
  147. nextTick(() => {
  148. let component = proxy;
  149. // #ifdef MP
  150. let num = 0; // 记录已处理的表单项数量
  151. // 并查找其错误节点的位置
  152. const deep = (el: any, index: number) => {
  153. // 遍历当前节点的所有子节点
  154. el?.$children.map((e: any) => {
  155. // 限制递归深度,防止死循环
  156. if (index < 5) {
  157. // 判断是否为 cl-form-item 组件且 prop 存在
  158. if (e.prop != null && e.$options.name == "cl-form-item") {
  159. // 如果该字段已注册到 fields 中,则计数加一
  160. if (fields.value.has(e.prop)) {
  161. num += 1;
  162. }
  163. // 查询该 cl-form-item 下是否有错误节点,并获取其位置信息
  164. uni.createSelectorQuery()
  165. .in(e)
  166. .select(".cl-form-item--error")
  167. .boundingClientRect((res) => {
  168. // 如果未获取到节点信息,直接返回
  169. if (res == null) {
  170. return;
  171. }
  172. // 记录该字段的错误节点 top 值
  173. tops.set(e.prop, (res as NodeInfo).top!);
  174. // 如果已处理的表单项数量达到总数,执行 done 回调
  175. if (num >= fields.value.size) {
  176. done();
  177. }
  178. })
  179. .exec();
  180. }
  181. // 递归查找子节点
  182. deep(e, index + 1);
  183. }
  184. });
  185. };
  186. deep(component, 0);
  187. // #endif
  188. // #ifndef MP
  189. uni.createSelectorQuery()
  190. .in(component)
  191. .selectAll(".cl-form-item--error")
  192. .boundingClientRect((res) => {
  193. (res as NodeInfo[]).map((e) => {
  194. tops.set((e.id ?? "").replace("cl-form-item-", ""), e.top ?? 0);
  195. });
  196. done();
  197. })
  198. .exec();
  199. // #endif
  200. });
  201. });
  202. }
  203. // 清除所有错误信息
  204. function clearErrors() {
  205. errors.value.clear();
  206. }
  207. // 获取字段值
  208. function getValue(prop: string): any | null {
  209. if (prop != "") {
  210. return get(data.value, prop, null);
  211. }
  212. return null;
  213. }
  214. // 获取字段规则
  215. function getRule(prop: string): ClFormRule[] {
  216. return props.rules.get(prop) ?? ([] as ClFormRule[]);
  217. }
  218. // 设置字段规则
  219. function setRule(prop: string, rules: ClFormRule[]) {
  220. if (prop != "" && !isEmpty(rules)) {
  221. props.rules.set(prop, rules);
  222. }
  223. }
  224. // 移除字段规则
  225. function removeRule(prop: string) {
  226. if (prop != "") {
  227. props.rules.delete(prop);
  228. }
  229. }
  230. // 注册表单字段
  231. function addField(prop: string, rules: ClFormRule[]) {
  232. if (prop != "") {
  233. fields.value.add(prop);
  234. setRule(prop, rules);
  235. }
  236. }
  237. // 注销表单字段
  238. function removeField(prop: string) {
  239. if (prop != "") {
  240. fields.value.delete(prop);
  241. removeRule(prop);
  242. removeError(prop);
  243. }
  244. }
  245. // 验证单个规则
  246. function validateRule(value: any | null, rule: ClFormRule): null | string {
  247. // 必填验证
  248. if (rule.required == true) {
  249. if (
  250. value == null ||
  251. (value == "" && isString(value)) ||
  252. (Array.isArray(value) && value.length == 0)
  253. ) {
  254. return rule.message ?? t("此字段为必填项");
  255. }
  256. }
  257. // 如果值为空且不是必填,直接通过
  258. if ((value == null || (value == "" && isString(value))) && rule.required != true) {
  259. return null;
  260. }
  261. // 最小长度验证
  262. if (rule.min != null) {
  263. if (typeof value == "number") {
  264. if ((value as number) < rule.min) {
  265. return rule.message ?? $t(`最小值为{min}`, { min: rule.min });
  266. }
  267. } else {
  268. const len = Array.isArray(value) ? value.length : `${value}`.length;
  269. if (len < rule.min) {
  270. return rule.message ?? $t(`最少需要{min}个字符`, { min: rule.min });
  271. }
  272. }
  273. }
  274. // 最大长度验证
  275. if (rule.max != null) {
  276. if (typeof value == "number") {
  277. if (value > rule.max) {
  278. return rule.message ?? $t(`最大值为{max}`, { max: rule.max });
  279. }
  280. } else {
  281. const len = Array.isArray(value) ? value.length : `${value}`.length;
  282. if (len > rule.max) {
  283. return rule.message ?? $t(`最多允许{max}个字符`, { max: rule.max });
  284. }
  285. }
  286. }
  287. // 正则验证
  288. if (rule.pattern != null) {
  289. if (!rule.pattern.test(`${value}`)) {
  290. return rule.message ?? t("格式不正确");
  291. }
  292. }
  293. // 自定义验证
  294. if (rule.validator != null) {
  295. const result = rule.validator(value);
  296. if (result != true) {
  297. return typeof result == "string" ? result : (rule.message ?? t("验证失败"));
  298. }
  299. }
  300. return null;
  301. }
  302. // 清除所有验证
  303. function clearValidate() {
  304. errorLock.value = true;
  305. nextTick(() => {
  306. clearErrors();
  307. errorLock.value = false;
  308. });
  309. }
  310. // 验证单个字段
  311. function validateField(prop: string): string | null {
  312. let error = null as string | null;
  313. if (prop != "") {
  314. const value = getValue(prop);
  315. const rules = getRule(prop);
  316. if (!isEmpty(rules)) {
  317. // 逐个验证规则
  318. rules.find((rule) => {
  319. const msg = validateRule(value, rule);
  320. if (msg != null) {
  321. error = msg;
  322. return true;
  323. }
  324. return false;
  325. });
  326. }
  327. // 移除错误信息
  328. removeError(prop);
  329. }
  330. if (error != null) {
  331. setError(prop, error!);
  332. }
  333. return error;
  334. }
  335. // 验证整个表单
  336. async function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) {
  337. // 验证所有字段
  338. fields.value.forEach((prop) => {
  339. validateField(prop);
  340. });
  341. // 获取所有错误信息,并滚动到第一个错误位置
  342. const errs = await getErrors();
  343. // 回调
  344. callback(errs.length == 0, errs);
  345. }
  346. watch(
  347. computed(() => parseToObject(props.modelValue)),
  348. (val: UTSJSONObject) => {
  349. data.value = val;
  350. },
  351. {
  352. immediate: true,
  353. deep: true
  354. }
  355. );
  356. defineExpose({
  357. labelPosition,
  358. labelWidth,
  359. showAsterisk,
  360. showMessage,
  361. disabled,
  362. data,
  363. errors,
  364. fields,
  365. addField,
  366. removeField,
  367. getValue,
  368. setError,
  369. getError,
  370. getErrors,
  371. removeError,
  372. clearErrors,
  373. getRule,
  374. setRule,
  375. removeRule,
  376. validateRule,
  377. clearValidate,
  378. validateField,
  379. validate
  380. });
  381. </script>
  382. <style lang="scss" scoped>
  383. .cl-form {
  384. @apply w-full;
  385. }
  386. </style>