Browse Source

feta: form 表单组件,支持校验 (#846)

richard1015 4 years ago
parent
commit
6867016d5c

+ 1 - 1
package.json

@@ -64,7 +64,7 @@
     }
   },
   "dependencies": {
-    "sass": "^1.34.0",
+    "sass": "~1.32.0",
     "vue-router": "^4.0.11"
   },
   "devDependencies": {

+ 24 - 1
src/config.json

@@ -556,7 +556,7 @@
           "sort": 2,
           "cName": "菜单组件",
           "type": "component",
-          "taro":true,
+          "taro": true,
           "show": true,
           "desc": "下拉菜单组件",
           "author": "yangjinjun3"
@@ -871,6 +871,29 @@
           "show": true,
           "taro": true,
           "author": "liqiong"
+        },
+        {
+          "version": "3.0.0",
+          "name": "Form",
+          "type": "component",
+          "cName": "表单",
+          "desc": "1",
+          "sort": 21,
+          "show": true,
+          "taro": true,
+          "author": "richard1015"
+        },
+        {
+          "version": "3.0.0",
+          "name": "FormItem",
+          "type": "component",
+          "cName": "表单Item",
+          "desc": "表单Item",
+          "sort": 22,
+          "show": false,
+          "taro": true,
+          "exportEmpty": true,
+          "author": "richard1015"
         }
       ]
     },

+ 1 - 1
src/packages/__VUE/checkbox/index.scss

@@ -10,7 +10,7 @@
   }
   &__label {
     margin-left: 15px;
-    font-size: 16px;
+    font-size: 14px;
     color: $checkbox-label-color;
     &--disabled {
       color: $checkbox-label-disable-color;

+ 132 - 0
src/packages/__VUE/form/common.ts

@@ -0,0 +1,132 @@
+import { isPromise } from '../../utils/util';
+import { computed, provide, reactive, VNode, watch } from 'vue';
+import { FormItemRule } from '../formitem/types';
+import { ErrorMessage, FormRule } from './types';
+
+export const component = {
+  props: {
+    modelValue: {
+      type: Object,
+      default: {}
+    }
+  },
+  components: {},
+  emits: ['validate'],
+
+  setup(props: any, { emit, slots }: any) {
+    const formErrorTip = computed(() => reactive<any>({}));
+    provide('formErrorTip', formErrorTip);
+    const init = (value = props.modelValue) => {
+      Object.keys(value).forEach((item) => {
+        formErrorTip.value[item] = '';
+      });
+    };
+
+    const reset = () => {
+      init();
+    };
+
+    watch(
+      () => props.modelValue,
+      (value: any) => {
+        init(value);
+      },
+      { immediate: true }
+    );
+
+    const findFormItem = (vnodes: VNode[]) => {
+      let task: FormRule[] = [];
+      vnodes.forEach((vnode: VNode, index: number) => {
+        let type = vnode.type;
+        type = (type as any).name || type;
+        if (type == 'nut-form-item') {
+          task.push({
+            prop: vnode.props?.['prop'],
+            rules: vnode.props?.['rules'] || []
+          });
+        }
+      });
+      return task;
+    };
+
+    const tipMessage = (errorMsg: ErrorMessage) => {
+      if (errorMsg.message) {
+        emit('validate', errorMsg);
+      }
+      formErrorTip.value[errorMsg.prop] = errorMsg.message;
+    };
+
+    const checkRule = (item: FormRule): Promise<ErrorMessage | boolean> => {
+      const { rules, prop } = item;
+
+      const _Promise = (errorMsg: ErrorMessage): Promise<ErrorMessage> => {
+        return new Promise((resolve, reject) => {
+          tipMessage(errorMsg);
+          resolve(errorMsg);
+        });
+      };
+
+      const value = props.modelValue[prop];
+      // clear tips
+      tipMessage({ prop, message: '' });
+
+      while (rules.length) {
+        const { required, validator, regex, message } = rules.shift() as FormItemRule;
+        const errorMsg = { prop, message };
+        if (required) {
+          if (!value) {
+            return _Promise(errorMsg);
+          }
+        }
+        if (regex && !regex.test(String(value))) {
+          return _Promise(errorMsg);
+        }
+        if (validator) {
+          const result = validator(value);
+          if (isPromise(result)) {
+            return new Promise((r, j) => {
+              result.then((res) => {
+                if (!res) {
+                  tipMessage(errorMsg);
+                  r(errorMsg);
+                } else {
+                  r(true);
+                }
+              });
+            });
+          } else {
+            if (!result) {
+              return _Promise(errorMsg);
+            }
+          }
+        }
+      }
+      return Promise.resolve(true);
+    };
+
+    const validate = () => {
+      return new Promise((resolve, reject) => {
+        let task = findFormItem(slots.default());
+
+        let errors = task.map((item) => {
+          return checkRule(item);
+        });
+
+        Promise.all(errors).then((errorRes) => {
+          errorRes = errorRes.filter((item) => item != true);
+          const res = { valid: true, errors: [] };
+          if (errorRes.length) {
+            res.valid = false;
+            res.errors = errorRes as any;
+          }
+          resolve(res);
+        });
+      });
+    };
+    const onSubmit = () => {
+      validate();
+      return false;
+    };
+    return { validate, reset, onSubmit, formErrorTip };
+  }
+};

+ 230 - 0
src/packages/__VUE/form/demo.vue

@@ -0,0 +1,230 @@
+<template>
+  <div class="demo full">
+    <h2>基本用法</h2>
+    <nut-form>
+      <nut-form-item label="姓名">
+        <input class="nut-input-text" placeholder="请输入姓名" type="text" />
+      </nut-form-item>
+      <nut-form-item label="年龄">
+        <input class="nut-input-text" placeholder="请输入年龄" type="text" />
+      </nut-form-item>
+      <nut-form-item label="联系电话">
+        <input class="nut-input-text" placeholder="请输入联系电话" type="text" />
+      </nut-form-item>
+      <nut-form-item label="地址">
+        <input class="nut-input-text" placeholder="请输入地址" type="text" />
+      </nut-form-item>
+    </nut-form>
+    <h2>表单校验</h2>
+    <nut-form :model-value="formData" ref="ruleForm">
+      <nut-form-item label="姓名" prop="name" required :rules="[{ required: true, message: '请填写姓名' }]">
+        <input class="nut-input-text" v-model="formData.name" placeholder="请输入姓名" type="text" />
+      </nut-form-item>
+      <nut-form-item
+        label="年龄"
+        prop="age"
+        required
+        :rules="[
+          { required: true, message: '请填写年龄' },
+          { validator: customValidator, message: '必须输入数字' },
+          { regex: /^(\d{1,2}|1\d{2}|200)$/, message: '必须输入0-200区间' }
+        ]"
+      >
+        <input
+          class="nut-input-text"
+          v-model="formData.age"
+          placeholder="请输入年龄,必须数字且0-200区间"
+          type="text"
+        />
+      </nut-form-item>
+      <nut-form-item
+        label="联系电话"
+        prop="tel"
+        required
+        :rules="[
+          { required: true, message: '请填写联系电话' },
+          { validator: asyncValidator, message: '电话格式不正确' }
+        ]"
+      >
+        <input
+          class="nut-input-text"
+          v-model="formData.tel"
+          placeholder="请输入联系电话,异步校验电话格式"
+          type="text"
+        />
+      </nut-form-item>
+      <nut-form-item label="地址" prop="address" required :rules="[{ required: true, message: '请填写地址' }]">
+        <input class="nut-input-text" v-model="formData.address" placeholder="请输入地址" type="text" />
+      </nut-form-item>
+      <nut-cell>
+        <nut-button type="primary" size="small" style="margin-right: 10px" @click="submit">提交</nut-button>
+        <nut-button size="small" @click="reset">重置提示状态</nut-button>
+      </nut-cell>
+    </nut-form>
+    <h2>表单类型</h2>
+    <nut-form>
+      <nut-form-item label="开关">
+        <nut-switch v-model="formData2.switch"></nut-switch>
+      </nut-form-item>
+      <nut-form-item label="复选框">
+        <nut-checkbox v-model="formData2.checkbox">复选框</nut-checkbox>
+      </nut-form-item>
+      <nut-form-item label="单选按钮">
+        <nut-radiogroup direction="horizontal" v-model="formData2.radio">
+          <nut-radio label="1">选项1</nut-radio>
+          <nut-radio disabled label="2">选项2</nut-radio>
+          <nut-radio label="3">选项3</nut-radio>
+        </nut-radiogroup>
+      </nut-form-item>
+      <nut-form-item label="评分">
+        <nut-rate v-model="formData2.rate" />
+      </nut-form-item>
+      <nut-form-item label="步进器">
+        <nut-inputnumber v-model="formData2.number" />
+      </nut-form-item>
+      <nut-form-item label="滑块">
+        <nut-range hidden-tag v-model="formData2.range"></nut-range>
+      </nut-form-item>
+      <nut-form-item label="文件上传">
+        <nut-uploader url="http://服务地址" v-model:file-list="formData2.defaultFileList" maximum="3" multiple>
+        </nut-uploader>
+      </nut-form-item>
+      <nut-form-item label="地址">
+        <input
+          class="nut-input-text"
+          v-model="formData2.address"
+          @click="addressModule.methods.show"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <!-- nut-address -->
+        <nut-address
+          v-model:visible="addressModule.state.show"
+          :province="addressModule.state.province"
+          :city="addressModule.state.city"
+          :country="addressModule.state.country"
+          :town="addressModule.state.town"
+          @change="addressModule.methods.onChange"
+          custom-address-title="请选择所在地区"
+        ></nut-address>
+      </nut-form-item>
+    </nut-form>
+  </div>
+</template>
+
+<script lang="ts">
+import { Toast } from '@/packages/nutui.vue';
+import { reactive, ref } from 'vue';
+import { createComponent } from '../../utils/create';
+const { createDemo } = createComponent('form');
+export default createDemo({
+  props: {},
+  setup() {
+    const formData = reactive({
+      name: '',
+      age: '',
+      tel: '',
+      address: ''
+    });
+    const validate = (item: any) => {
+      console.log(item);
+    };
+
+    const formData2 = reactive({
+      switch: false,
+      checkbox: false,
+      radio: 0,
+      number: 0,
+      rate: 3,
+      range: 30,
+      address: '',
+      defaultFileList: [
+        {
+          name: '文件1.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'success',
+          message: '上传成功',
+          type: 'image'
+        },
+        {
+          name: '文件2.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'uploading',
+          message: '上传中...',
+          type: 'image'
+        }
+      ]
+    });
+
+    const addressModule = reactive({
+      state: {
+        show: false,
+        province: [
+          { id: 1, name: '北京' },
+          { id: 2, name: '广西' },
+          { id: 3, name: '江西' },
+          { id: 4, name: '四川' }
+        ],
+        city: [
+          { id: 7, name: '朝阳区' },
+          { id: 8, name: '崇文区' },
+          { id: 9, name: '昌平区' },
+          { id: 6, name: '石景山区' }
+        ],
+        country: [
+          { id: 3, name: '八里庄街道' },
+          { id: 9, name: '北苑' },
+          { id: 4, name: '常营乡' }
+        ],
+        town: []
+      },
+      methods: {
+        show() {
+          addressModule.state.show = !addressModule.state.show;
+          if (addressModule.state.show) {
+            formData2.address = '';
+          }
+        },
+        onChange({ custom, next, value }: any) {
+          formData2.address += value.name;
+          const name = addressModule.state[next];
+          if (name.length < 1) {
+            addressModule.state.show = false;
+          }
+        }
+      }
+    });
+
+    const ruleForm = ref<any>(null);
+
+    const submit = () => {
+      ruleForm.value.validate().then(({ valid, errors }: any) => {
+        if (valid) {
+          console.log('success', formData);
+        } else {
+          console.log('error submit!!', errors);
+        }
+      });
+    };
+    const reset = () => {
+      ruleForm.value.reset();
+    };
+    // 函数校验
+    const customValidator = (val: string) => /^\d+$/.test(val);
+    // Promise 异步校验
+    const asyncValidator = (val: string) => {
+      return new Promise((resolve) => {
+        Toast.loading('模拟异步验证中...');
+        setTimeout(() => {
+          Toast.hide();
+          resolve(/^400(-?)[0-9]{7}$|^1\d{10}$|^0[0-9]{2,3}-[0-9]{7,8}$/.test(val));
+        }, 1000);
+      });
+    };
+    return { ruleForm, formData, validate, customValidator, asyncValidator, submit, reset, formData2, addressModule };
+  }
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 268 - 0
src/packages/__VUE/form/doc.md

@@ -0,0 +1,268 @@
+# Form 表单组件
+
+### 介绍
+
+用于数据录入、校验,支持输入框、单选框、复选框、文件上传等类型,需要与 Cell 组件搭配使用。
+
+### 安装
+
+    
+``` javascript
+import { createApp } from 'vue';
+// vue
+import { Form,FormItem,Cell,CellGroup } from '@nutui/nutui';
+// taro
+import { Form,FormItem,Cell,CellGroup  } from '@nutui/nutui-taro';
+
+const app = createApp();
+app.use(Form);
+app.use(FormItem);
+app.use(Cell);
+app.use(CellGroup);
+```
+
+
+### 基础用法
+
+``` html
+<nut-form>
+    <nut-form-item label="姓名">
+        <input class="nut-input-text" placeholder="请输入姓名" type="text" />
+    </nut-form-item>
+    <nut-form-item label="年龄">
+        <input class="nut-input-text" placeholder="请输入年龄" type="text" />
+    </nut-form-item>
+    <nut-form-item label="联系电话">
+        <input class="nut-input-text" placeholder="请输入联系电话" type="text" />
+    </nut-form-item>
+    <nut-form-item label="地址">
+        <input class="nut-input-text" placeholder="请输入地址" type="text" />
+    </nut-form-item>
+</nut-form>
+```
+
+### 表单校验
+
+``` html
+<nut-form :model-value="formData" ref="ruleForm">
+    <nut-form-item label="姓名" prop="name" required :rules="[{ required: true, message: '请填写姓名' }]">
+        <input class="nut-input-text" v-model="formData.name" placeholder="请输入姓名" type="text" />
+    </nut-form-item>
+    <nut-form-item label="年龄" prop="age" required :rules="[
+        { required: true, message: '请填写年龄' },
+        { validator: customValidator, message: '必须输入数字' },
+        { regex: /^(\d{1,2}|1\d{2}|200)$/, message: '必须输入0-200区间' }
+    ]">
+        <input class="nut-input-text" v-model="formData.age" placeholder="请输入年龄,必须数字且0-200区间" type="text" />
+    </nut-form-item>
+    <nut-form-item label="联系电话" prop="tel" required :rules="[
+        { required: true, message: '请填写联系电话' },
+        { validator: asyncValidator, message: '电话格式不正确' }
+    ]">
+        <input class="nut-input-text" v-model="formData.tel" placeholder="请输入联系电话,异步校验电话格式" type="text" />
+    </nut-form-item>
+    <nut-form-item label="地址" prop="address" required :rules="[{ required: true, message: '请填写地址' }]">
+        <input class="nut-input-text" v-model="formData.address" placeholder="请输入地址" type="text" />
+    </nut-form-item>
+    <nut-cell>
+        <nut-button type="primary" size="small" style="margin-right: 10px" @click="submit">提交</nut-button>
+        <nut-button size="small" @click="reset">重置提示状态</nut-button>
+    </nut-cell>
+</nut-form>
+```
+``` javascript
+setup(){
+    const formData = reactive({
+        name: '',
+        age: '',
+        tel: '',
+        address: ''
+    });
+    const validate = (item: any) => {
+        console.log(item);
+    };
+    const ruleForm = ref<any>(null);
+
+    const submit = () => {
+      ruleForm.value.validate().then(({ valid, errors }: any) => {
+        if (valid) {
+          console.log('success', formData);
+        } else {
+          console.log('error submit!!', errors);
+        }
+      });
+    };
+    const reset = () => {
+      ruleForm.value.reset();
+    };
+    // 函数校验
+    const customValidator = (val: string) => /^\d+$/.test(val);
+    // Promise 异步校验
+    const asyncValidator = (val: string) => {
+      return new Promise((resolve) => {
+        Toast.loading('模拟异步验证中...');
+        setTimeout(() => {
+          Toast.hide();
+          resolve(/^400(-?)[0-9]{7}$|^1\d{10}$|^0[0-9]{2,3}-[0-9]{7,8}$/.test(val));
+        }, 1000);
+      });
+    };
+    return { ruleForm, formData, validate, customValidator, asyncValidator, submit, reset, formData2, addressModule };
+}
+```
+
+
+### 表单类型
+``` html
+<nut-form>
+    <nut-form-item label="开关">
+        <nut-switch v-model="formData2.switch"></nut-switch>
+    </nut-form-item>
+    <nut-form-item label="复选框">
+        <nut-checkbox v-model="formData2.checkbox">复选框</nut-checkbox>
+    </nut-form-item>
+    <nut-form-item label="单选按钮">
+        <nut-radiogroup direction="horizontal" v-model="formData2.radio">
+            <nut-radio label="1">选项1</nut-radio>
+            <nut-radio disabled label="2">选项2</nut-radio>
+            <nut-radio label="3">选项3</nut-radio>
+        </nut-radiogroup>
+    </nut-form-item>
+    <nut-form-item label="评分">
+        <nut-rate v-model="formData2.rate" />
+    </nut-form-item>
+    <nut-form-item label="步进器">
+        <nut-inputnumber v-model="formData2.number" />
+    </nut-form-item>
+    <nut-form-item label="滑块">
+        <nut-range hidden-tag v-model="formData2.range"></nut-range>
+    </nut-form-item>
+    <nut-form-item label="文件上传">
+        <nut-uploader url="http://服务地址" v-model:file-list="formData2.defaultFileList" maximum="3" multiple>
+        </nut-uploader>
+    </nut-form-item>
+    <nut-form-item label="地址">
+        <input class="nut-input-text" v-model="formData2.address" @click="addressModule.methods.show" readonly
+            placeholder="请选择地址" type="text" />
+        <!-- nut-address -->
+        <nut-address v-model:visible="addressModule.state.show" :province="addressModule.state.province"
+            :city="addressModule.state.city" :country="addressModule.state.country" :town="addressModule.state.town"
+            @change="addressModule.methods.onChange" custom-address-title="请选择所在地区"></nut-address>
+    </nut-form-item>
+</nut-form>
+```
+``` javascript
+setup(){
+    const formData2 = reactive({
+      switch: false,
+      checkbox: false,
+      radio: 0,
+      number: 0,
+      rate: 3,
+      range: 30,
+      address: '',
+      defaultFileList: [
+        {
+          name: '文件1.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'success',
+          message: '上传成功',
+          type: 'image'
+        },
+        {
+          name: '文件2.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'uploading',
+          message: '上传中...',
+          type: 'image'
+        }
+      ]
+    });
+
+    const addressModule = reactive({
+      state: {
+        show: false,
+        province: [
+          { id: 1, name: '北京' },
+          { id: 2, name: '广西' },
+          { id: 3, name: '江西' },
+          { id: 4, name: '四川' }
+        ],
+        city: [
+          { id: 7, name: '朝阳区' },
+          { id: 8, name: '崇文区' },
+          { id: 9, name: '昌平区' },
+          { id: 6, name: '石景山区' }
+        ],
+        country: [
+          { id: 3, name: '八里庄街道' },
+          { id: 9, name: '北苑' },
+          { id: 4, name: '常营乡' }
+        ],
+        town: []
+      },
+      methods: {
+        show() {
+          addressModule.state.show = !addressModule.state.show;
+          if (addressModule.state.show) {
+            formData2.address = '';
+          }
+        },
+        onChange({ custom, next, value }: any) {
+          formData2.address += value.name;
+          const name = addressModule.state[next];
+          if (name.length < 1) {
+            addressModule.state.show = false;
+          }
+        }
+      }
+    });
+    return { formData2, addressModule };
+}
+```
+
+
+
+### Form Props
+
+| 参数        | 说明                                 | 类型   | 默认值 |
+|-------------|--------------------------------------|--------|--------|
+| model-value | 表单数据对象(使用表单校验时,_必填_) | object |        |
+
+### Form Events
+
+| 事件名   | 说明                       | 回调参数                                                   |
+|----------|----------------------------|------------------------------------------------------------|
+| validate | 任一表单项被校验失败后触发 | 被校验的表单项 prop 值,校验是否通过,错误消息(如果存在) |
+
+### FormItem Props
+
+| 参数                | 说明                                             | 类型             | 默认值  |
+|---------------------|--------------------------------------------------|------------------|---------|
+| required            | 是否显示必填字段的标签旁边的红色星号             | boolean          | `false` |
+| label-width         | 表单项 label 宽度,默认单位为`px`                | number \| string | `90px`  |
+| label-align         | 表单项 label 对齐方式,可选值为 `center` `right` | string           | `left`  |
+| body-align          | 输入框对齐方式,可选值为 `center` `right`        | string           | `left`  |
+| error-message-align | 错误提示文案对齐方式,可选值为 `center` `right`  | string           | `left`  |
+| show-error-line     | 是否在校验不通过时标红输入框                     | boolean          | `true`  |
+| show-error-message  | 是否在校验不通过时在输入框下方展示错误提示       | boolean          | `true`  |
+
+### FormItem Rule 数据结构
+
+使用 FormItem 的`rules`属性可以定义校验规则,可选属性如下:
+
+| 键名      | 说明                   | 类型                                          |
+|-----------|------------------------|-----------------------------------------------|
+| required  | 是否为必选字段         | boolean                                       |
+| message   | 错误提示文案           | string                                        |
+| validator | 通过函数进行校验       | (value, rule) => boolean \| string \| Promise |
+| regex     | 通过正则表达式进行校验 | RegExp                                        |
+
+### Methods
+
+通过 [ref](https://v3.cn.vuejs.org/api/special-attributes.html#ref) 可以获取到 Form 实例并调用实例方法
+
+| 方法名 | 说明                   | 参数 | 返回值  |
+|--------|------------------------|------|---------|
+| submit | 提交表单进行校验的方法 | -    | Promise |
+| reset  | 清空校验结果           | -    | -       |

+ 2 - 0
src/packages/__VUE/form/index.scss

@@ -0,0 +1,2 @@
+.nut-form {
+}

+ 13 - 0
src/packages/__VUE/form/index.taro.vue

@@ -0,0 +1,13 @@
+<template>
+  <form class="nut-form" @submit="onSubmit">
+    <nut-cell-group>
+      <slot></slot>
+    </nut-cell-group>
+  </form>
+</template>
+<script lang="ts">
+import { createComponent } from '../../utils/create';
+const { create } = createComponent('form');
+import { component } from './common';
+export default create(component);
+</script>

+ 13 - 0
src/packages/__VUE/form/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <form class="nut-form" @submit="onSubmit">
+    <nut-cell-group>
+      <slot></slot>
+    </nut-cell-group>
+  </form>
+</template>
+<script lang="ts">
+import { createComponent } from '../../utils/create';
+const { create } = createComponent('form');
+import { component } from './common';
+export default create(component);
+</script>

+ 10 - 0
src/packages/__VUE/form/types.ts

@@ -0,0 +1,10 @@
+import { FormItemRule } from '../formitem/types';
+
+export type FormRule = {
+  prop: string;
+  rules: FormItemRule[];
+};
+export type ErrorMessage = {
+  prop: string;
+  message: string;
+};

+ 0 - 0
src/packages/__VUE/formitem/common.ts


+ 60 - 0
src/packages/__VUE/formitem/index.scss

@@ -0,0 +1,60 @@
+.nut-form-item {
+  display: flex;
+  &::before {
+    position: absolute;
+    box-sizing: border-box;
+    content: ' ';
+    pointer-events: none;
+    right: 16px;
+    bottom: 0;
+    left: 16px;
+    transform: scaleX(0);
+  }
+  &.error {
+    &.line {
+      &::before {
+        border-bottom: 1px solid $form-item-error-line-color;
+        transform: scaleX(1);
+        transition: transform 200ms cubic-bezier(0, 0, 0.2, 1) 0ms;
+      }
+    }
+  }
+
+  &__label {
+    font-size: 14px;
+    font-weight: normal;
+    width: 90px;
+    margin-right: 10px;
+    flex: none;
+    display: inline-block;
+    word-wrap: break-word;
+    &.required {
+      &::before {
+        content: '*';
+        color: $form-item-required-color;
+        margin-right: 4px;
+      }
+    }
+  }
+  &__body {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    &__slots {
+      .nut-input-text {
+        font-size: 14px;
+        width: 100%;
+        outline: 0 none;
+        border: 0;
+        text-decoration: none;
+      }
+      .nut-range-container {
+        min-height: 24px;
+      }
+    }
+    &__tips {
+      font-size: 10px;
+      color: $form-item-error-message-color;
+    }
+  }
+}

+ 94 - 0
src/packages/__VUE/formitem/index.vue

@@ -0,0 +1,94 @@
+<template>
+  <nut-cell class="nut-form-item" :class="{ error: parent[prop], line: showErrorLine }">
+    <view class="nut-cell__title nut-form-item__label" :style="labelStyle" v-if="label" :class="{ required: required }">
+      {{ label }}</view
+    >
+    <view class="nut-cell__value nut-form-item__body">
+      <view class="nut-form-item__body__slots" :style="bodyStyle">
+        <slot></slot>
+      </view>
+      <view class="nut-form-item__body__tips" :style="errorMessageStyle" v-if="parent[prop] && showErrorMessage">
+        {{ parent[prop] }}</view
+      >
+    </view>
+  </nut-cell>
+</template>
+<script lang="ts">
+import { pxCheck } from '../../utils/pxCheck';
+import { computed, inject, PropType, ref } from 'vue';
+import { createComponent } from '../../utils/create';
+const { componentName, create } = createComponent('form-item');
+import { FormItemRule } from './types';
+export default create({
+  inheritAttrs: false,
+  props: {
+    prop: {
+      type: String,
+      default: ''
+    },
+    label: {
+      type: String,
+      default: ''
+    },
+    rules: {
+      type: Array as PropType<FormItemRule[]>,
+      default: () => {
+        return [];
+      }
+    },
+    required: {
+      type: Boolean,
+      default: false
+    },
+    showErrorMessage: {
+      type: Boolean,
+      default: true
+    },
+    showErrorLine: {
+      type: Boolean,
+      default: true
+    },
+    labelWidth: {
+      type: [String, Number],
+      default: ''
+    },
+    labelAlign: {
+      type: String,
+      default: 'left'
+    },
+    errorMessageAlign: {
+      type: String,
+      default: 'left'
+    },
+    bodyAlign: {
+      type: String,
+      default: 'left'
+    }
+  },
+  components: {},
+  emits: [''],
+
+  setup(props, { emit }) {
+    const parent = inject('formErrorTip') as any;
+
+    const labelStyle = computed(() => {
+      return {
+        width: pxCheck(props.labelWidth),
+        textAlign: props.labelAlign
+      };
+    });
+    const bodyStyle = computed(() => {
+      return {
+        textAlign: props.bodyAlign
+      };
+    });
+    const errorMessageStyle = computed(() => {
+      return {
+        textAlign: props.errorMessageAlign
+      };
+    });
+
+    return { parent, labelStyle, bodyStyle, errorMessageStyle };
+  }
+});
+</script>

+ 6 - 0
src/packages/__VUE/formitem/types.ts

@@ -0,0 +1,6 @@
+export class FormItemRule {
+  regex?: RegExp;
+  required?: boolean;
+  message!: string;
+  validator?: (value: any) => boolean | string | Promise<boolean | string>;
+}

+ 1 - 1
src/packages/__VUE/switch/index.scss

@@ -1,6 +1,6 @@
 .nut-switch {
   cursor: pointer;
-  display: flex;
+  display: inline-flex;
   align-items: center;
   background-color: $primary-color;
   border-radius: 21px;

+ 1 - 1
src/packages/__VUE/uploader/doc.md

@@ -240,7 +240,7 @@ setup() {
 
 ### Methods
 
-通过 [ref](https://v3.cn.vuejs.org/api/special-attributes.html#key) 可以获取到 Uploader 实例并调用实例方法
+通过 [ref](https://v3.cn.vuejs.org/api/special-attributes.html#ref) 可以获取到 Uploader 实例并调用实例方法
 
 | 方法名           | 说明                                                       | 参数 | 返回值 |
 |------------------|------------------------------------------------------------|------|--------|

+ 1 - 1
src/packages/__VUE/uploader/doc.taro.md

@@ -219,7 +219,7 @@ setup() {
 
 ### Methods
 
-通过 [ref](https://v3.cn.vuejs.org/api/special-attributes.html#key) 可以获取到 Uploader 实例并调用实例方法
+通过 [ref](https://v3.cn.vuejs.org/api/special-attributes.html#ref) 可以获取到 Uploader 实例并调用实例方法
 
 | 方法名           | 说明                                                       | 参数 | 返回值 |
 |------------------|------------------------------------------------------------|------|--------|

+ 5 - 0
src/packages/styles/variables.scss

@@ -378,6 +378,11 @@ $searchbar-background: $white !default;
 $searchbar-input-background: #f7f7f7 !default;
 $searchbar-right-out-color: $black !default;
 
+// form
+$form-item-error-line-color: $primary-color !default;
+$form-item-required-color: $primary-color !default;
+$form-item-error-message-color: $primary-color !default;
+
 // sku
 $sku-item-border: 1px solid $primary-color;
 $sku-item-disable-line: line-through;

+ 21 - 0
src/packages/utils/util.ts

@@ -37,3 +37,24 @@ export const TypeOfFun = (value: any) => {
       return 'unknow';
   }
 };
+//
+export const objectToString = Object.prototype.toString;
+export const toTypeString = (value: unknown): string => objectToString.call(value);
+
+export const toRawType = (value: unknown): string => {
+  // extract "RawType" from strings like "[object RawType]"
+  return toTypeString(value).slice(8, -1);
+};
+export const isArray = Array.isArray;
+export const isMap = (val: unknown): val is Map<any, any> => toTypeString(val) === '[object Map]';
+export const isSet = (val: unknown): val is Set<any> => toTypeString(val) === '[object Set]';
+
+export const isDate = (val: unknown): val is Date => val instanceof Date;
+export const isFunction = (val: unknown): val is Function => typeof val === 'function';
+export const isString = (val: unknown): val is string => typeof val === 'string';
+export const isSymbol = (val: unknown): val is symbol => typeof val === 'symbol';
+export const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object';
+
+export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
+  return isObject(val) && isFunction(val.then) && isFunction(val.catch);
+};

+ 1 - 0
src/sites/mobile-taro/vue/src/app.config.ts

@@ -62,6 +62,7 @@ export default {
         'pages/rate/index',
         'pages/radio/index',
         'pages/calendar/index',
+        'pages/form/index',
         'pages/shortpassword/index',
         'pages/picker/index',
         'pages/datepicker/index',

+ 3 - 0
src/sites/mobile-taro/vue/src/dentry/pages/form/index.config.ts

@@ -0,0 +1,3 @@
+export default {
+  navigationBarTitleText: 'Form'
+};

+ 227 - 0
src/sites/mobile-taro/vue/src/dentry/pages/form/index.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="demo full">
+    <h2>基本用法</h2>
+    <nut-form>
+      <nut-form-item label="姓名">
+        <input class="nut-input-text" placeholder="请输入姓名" type="text" />
+      </nut-form-item>
+      <nut-form-item label="年龄">
+        <input class="nut-input-text" placeholder="请输入年龄" type="text" />
+      </nut-form-item>
+      <nut-form-item label="联系电话">
+        <input class="nut-input-text" placeholder="请输入联系电话" type="text" />
+      </nut-form-item>
+      <nut-form-item label="地址">
+        <input class="nut-input-text" placeholder="请输入地址" type="text" />
+      </nut-form-item>
+    </nut-form>
+    <h2>表单校验</h2>
+    <nut-form :model-value="formData" ref="ruleForm">
+      <nut-form-item label="姓名" prop="name" required :rules="[{ required: true, message: '请填写姓名' }]">
+        <input class="nut-input-text" v-model="formData.name" placeholder="请输入姓名" type="text" />
+      </nut-form-item>
+      <nut-form-item
+        label="年龄"
+        prop="age"
+        required
+        :rules="[
+          { required: true, message: '请填写年龄' },
+          { validator: customValidator, message: '必须输入数字' },
+          { regex: /^(\d{1,2}|1\d{2}|200)$/, message: '必须输入0-200区间' }
+        ]"
+      >
+        <input
+          class="nut-input-text"
+          v-model="formData.age"
+          placeholder="请输入年龄,必须数字且0-200区间"
+          type="text"
+        />
+      </nut-form-item>
+      <nut-form-item
+        label="联系电话"
+        prop="tel"
+        required
+        :rules="[
+          { required: true, message: '请填写联系电话' },
+          { validator: asyncValidator, message: '电话格式不正确' }
+        ]"
+      >
+        <input
+          class="nut-input-text"
+          v-model="formData.tel"
+          placeholder="请输入联系电话,异步校验电话格式"
+          type="text"
+        />
+      </nut-form-item>
+      <nut-form-item label="地址" prop="address" required :rules="[{ required: true, message: '请填写地址' }]">
+        <input class="nut-input-text" v-model="formData.address" placeholder="请输入地址" type="text" />
+      </nut-form-item>
+      <nut-cell>
+        <nut-button type="primary" size="small" style="margin-right: 10px" @click="submit">提交</nut-button>
+        <nut-button size="small" @click="reset">重置提示状态</nut-button>
+      </nut-cell>
+    </nut-form>
+    <h2>表单类型</h2>
+    <nut-form>
+      <nut-form-item label="开关">
+        <nut-switch v-model="formData2.switch"></nut-switch>
+      </nut-form-item>
+      <nut-form-item label="复选框">
+        <nut-checkbox v-model="formData2.checkbox">复选框</nut-checkbox>
+      </nut-form-item>
+      <nut-form-item label="单选按钮">
+        <nut-radiogroup direction="horizontal" v-model="formData2.radio">
+          <nut-radio label="1">选项1</nut-radio>
+          <nut-radio disabled label="2">选项2</nut-radio>
+          <nut-radio label="3">选项3</nut-radio>
+        </nut-radiogroup>
+      </nut-form-item>
+      <nut-form-item label="评分">
+        <nut-rate v-model="formData2.rate" />
+      </nut-form-item>
+      <nut-form-item label="步进器">
+        <nut-inputnumber v-model="formData2.number" />
+      </nut-form-item>
+      <nut-form-item label="滑块">
+        <nut-range hidden-tag v-model="formData2.range"></nut-range>
+      </nut-form-item>
+      <nut-form-item label="文件上传">
+        <nut-uploader url="http://服务地址" v-model:file-list="formData2.defaultFileList" maximum="3" multiple>
+        </nut-uploader>
+      </nut-form-item>
+      <nut-form-item label="地址">
+        <input
+          class="nut-input-text"
+          v-model="formData2.address"
+          @click="addressModule.methods.show"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <!-- nut-address -->
+        <nut-address
+          v-model:visible="addressModule.state.show"
+          :province="addressModule.state.province"
+          :city="addressModule.state.city"
+          :country="addressModule.state.country"
+          :town="addressModule.state.town"
+          @change="addressModule.methods.onChange"
+          custom-address-title="请选择所在地区"
+        ></nut-address>
+      </nut-form-item>
+    </nut-form>
+  </div>
+</template>
+
+<script lang="ts">
+import { reactive, ref } from 'vue';
+export default {
+  props: {},
+  setup() {
+    const formData = reactive({
+      name: '',
+      age: '',
+      tel: '',
+      address: ''
+    });
+    const validate = (item: any) => {
+      console.log(item);
+    };
+
+    const formData2 = reactive({
+      switch: false,
+      checkbox: false,
+      radio: 0,
+      number: 0,
+      rate: 3,
+      range: 30,
+      address: '',
+      defaultFileList: [
+        {
+          name: '文件1.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'success',
+          message: '上传成功',
+          type: 'image'
+        },
+        {
+          name: '文件2.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'uploading',
+          message: '上传中...',
+          type: 'image'
+        }
+      ]
+    });
+
+    const addressModule = reactive({
+      state: {
+        show: false,
+        province: [
+          { id: 1, name: '北京' },
+          { id: 2, name: '广西' },
+          { id: 3, name: '江西' },
+          { id: 4, name: '四川' }
+        ],
+        city: [
+          { id: 7, name: '朝阳区' },
+          { id: 8, name: '崇文区' },
+          { id: 9, name: '昌平区' },
+          { id: 6, name: '石景山区' }
+        ],
+        country: [
+          { id: 3, name: '八里庄街道' },
+          { id: 9, name: '北苑' },
+          { id: 4, name: '常营乡' }
+        ],
+        town: []
+      },
+      methods: {
+        show() {
+          addressModule.state.show = !addressModule.state.show;
+          if (addressModule.state.show) {
+            formData2.address = '';
+          }
+        },
+        onChange({ custom, next, value }: any) {
+          formData2.address += value.name;
+          const name = addressModule.state[next];
+          if (name.length < 1) {
+            addressModule.state.show = false;
+          }
+        }
+      }
+    });
+
+    const ruleForm = ref<any>(null);
+
+    const submit = () => {
+      ruleForm.value.validate().then(({ valid, errors }: any) => {
+        if (valid) {
+          console.log('success', formData);
+        } else {
+          console.log('error submit!!', errors);
+        }
+      });
+    };
+    const reset = () => {
+      ruleForm.value.reset();
+    };
+    // 函数校验
+    const customValidator = (val: string) => /^\d+$/.test(val);
+    // Promise 异步校验
+    const asyncValidator = (val: string) => {
+      return new Promise((resolve) => {
+        console.log('模拟异步验证中...');
+        setTimeout(() => {
+          console.log('验证完成');
+          resolve(/^400(-?)[0-9]{7}$|^1\d{10}$|^0[0-9]{2,3}-[0-9]{7,8}$/.test(val));
+        }, 1000);
+      });
+    };
+    return { ruleForm, formData, validate, customValidator, asyncValidator, submit, reset, formData2, addressModule };
+  }
+};
+</script>
+
+<style lang="scss" scoped></style>