From 97244e3106b55f3c9a9e26bc6bfe79c99a78857f Mon Sep 17 00:00:00 2001
From: lq <1049229070@qq.com>
Date: Wed, 19 Jun 2024 19:41:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EverificationCode?=
=?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E6=B8=B2=E6=9F=93=E5=99=A8=20(#1038?=
=?UTF-8?q?6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 新增verificationCode验证码渲染器和页面设计器
* fix: verificationCode to input-verification-code
---
.../form/input-verification-code.md | 160 +++++++++
examples/components/Components.tsx | 9 +
.../scss/components/_verificationCode.scss | 33 ++
packages/amis-ui/scss/themes/_common.scss | 1 +
.../src/components/VerificationCode.tsx | 316 ++++++++++++++++++
packages/amis-ui/src/components/index.tsx | 4 +-
packages/amis/src/Schema.ts | 1 +
packages/amis/src/index.tsx | 1 +
.../renderers/Form/InputVerificationCode.tsx | 102 ++++++
9 files changed, 626 insertions(+), 1 deletion(-)
create mode 100644 docs/zh-CN/components/form/input-verification-code.md
create mode 100644 packages/amis-ui/scss/components/_verificationCode.scss
create mode 100644 packages/amis-ui/src/components/VerificationCode.tsx
create mode 100644 packages/amis/src/renderers/Form/InputVerificationCode.tsx
diff --git a/docs/zh-CN/components/form/input-verification-code.md b/docs/zh-CN/components/form/input-verification-code.md
new file mode 100644
index 000000000..4c11d0dd8
--- /dev/null
+++ b/docs/zh-CN/components/form/input-verification-code.md
@@ -0,0 +1,160 @@
+---
+title: 验证码输入 InputVerificationCode
+description:
+type: 0
+group: null
+menuName: InputVerificationCode 验证码
+icon:
+order: 63
+---
+
+注意 InputVerificationCode, 可通过粘贴完成填充数据。
+
+## 基本用法
+
+基本用法。
+
+```schema: scope="body"
+{
+ "type": "form",
+ "api": "/api/mock2/form/saveForm",
+ "debug": true,
+ "body": [
+ {
+ "type": "input-verification-code",
+ "name": "verificationCode"
+ },
+ ]
+}
+```
+
+## 密码模式
+
+指定 masked = true,可开启密码模式。
+
+```schema: scope="body"
+{
+ "type": "form",
+ "api": "/api/mock2/form/saveForm",
+ "debug": true,
+ "body": [
+ {
+ "type": "input-verification-code",
+ "name": "verificationCode",
+ "masked": true,
+ }
+ ]
+}
+```
+
+## 自定义分隔符
+
+指定 separator 可以自定义渲染分隔符。
+
+```schema: scope="body"
+{
+ "type": "form",
+ "api": "/api/mock2/form/saveForm",
+ "debug": true,
+ "body": [
+ {
+ "type": "input-verification-code",
+ "name": "verificationCode",
+ "length": 9,
+ "separator": "${((index + 1) % 3 || index > 7 ) ? null : '-'}",
+ }
+ ]
+}
+```
+
+## 状态
+
+
指定 disabled = true,可开启禁用模式。
+指定 readOnly = true,可开启只读模式。
+
+```schema: scope="body"
+{
+ "type": "form",
+ "api": "/api/mock2/form/saveForm",
+ "debug": true,
+ "body": [
+ {
+ "type": "input-verification-code",
+ "name": "verificationCodeDisabled",
+ "value": "123456",
+ "disabled": true,
+ },
+ {
+ "type": "input-verification-code",
+ "name": "verificationCodeReadOnly",
+ "value": "987654",
+ "readOnly": true,
+ }
+ ]
+}
+
+
+```
+
+## 属性表
+
+当做选择器表单项使用时,除了支持 [普通表单项属性表](./formitem#%E5%B1%9E%E6%80%A7%E8%A1%A8) 中的配置以外,还支持下面一些配置
+
+| 属性名 | 类型 | 默认值 | 说明 |
+| --------- | --------- | ------ | ------------------------------------------------------------------------------ |
+| length | `number` | 6 | 验证码的长度,根据长度渲染对应个数的输入框 |
+| masked | `boolean` | false | 是否是密码模式 |
+| separator | `string` | | 分隔符,支持表达式, 表达式`只`可以访问 index、character 变量, 参考自定义分隔符 |
+
+## 事件表
+
+当前组件会对外派发以下事件,可以通过`onEvent`来监听这些事件。
+
+| 事件名称 | 事件参数 | 说明 |
+| -------- | -------- | -------------------------- |
+| finish | - | 输入框都被填充后触发的回调 |
+| change | - | 输入值改变时触发的回调 |
+
+### 事件
+
+finish 输入框都被填充。可以尝试通过`${event.data.value}`获取填写的数据。
+
+```schema: scope="body"
+{
+ "type": "input-verification-code",
+ "onEvent": {
+ "finish": {
+ "actions": [
+ {
+ "actionType": "toast",
+ "args": {
+ "msgType": "info",
+ "msg": "${event.data.value}"
+ }
+ }
+ ]
+ }
+ }
+}
+```
+
+change 输入值改变。可以尝试通过`${event.data.value}`获取填写的数据。
+
+```schema: scope="body"
+{
+ "type": "input-verification-code",
+ "onEvent": {
+ "change": {
+ "actions": [
+ {
+ "actionType": "toast",
+ "args": {
+ "msgType": "info",
+ "msg": "${event.data.value}"
+ }
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/examples/components/Components.tsx b/examples/components/Components.tsx
index 8bf907488..459b04724 100644
--- a/examples/components/Components.tsx
+++ b/examples/components/Components.tsx
@@ -795,6 +795,15 @@ export const components = [
wrapDoc
)
)
+ },
+ {
+ label: 'InputVerificationCode 验证码',
+ path: '/zh-CN/components/form/input-verification-code',
+ component: React.lazy(() =>
+ import(
+ '../../docs/zh-CN/components/form/input-verification-code.md'
+ ).then(wrapDoc)
+ )
}
]
},
diff --git a/packages/amis-ui/scss/components/_verificationCode.scss b/packages/amis-ui/scss/components/_verificationCode.scss
new file mode 100644
index 000000000..ed70ceaf1
--- /dev/null
+++ b/packages/amis-ui/scss/components/_verificationCode.scss
@@ -0,0 +1,33 @@
+.#{$ns}Verification-code {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ column-gap: 4px;
+ overflow: hidden;
+ input {
+ width: px2rem(35px);
+ border: var(--Form-input-borderWidth) solid var(--Form-input-borderColor);
+ border-radius: var(--Form-input-borderRadius);
+ line-height: var(--Form-input-lineHeight);
+ padding: var(--Form-input-paddingY) var(--Form-input-paddingX);
+ font-size: var(--Form-input-fontSize);
+ text-align: center;
+
+ &:focus {
+ border-color: var(--Form-input-onFocused-borderColor);
+ box-shadow: var(--Form-input-boxShadow);
+ background: var(--Form-input-onFocused-bg);
+ }
+
+ &:hover {
+ border-color: var(--Form-input-onFocused-borderColor);
+ }
+
+ &.is-disabled {
+ cursor: not-allowed;
+ background: var(--Form-input-onDisabled-bg);
+ border-color: var(--Form-input-onDisabled-borderColor);
+ }
+ }
+}
diff --git a/packages/amis-ui/scss/themes/_common.scss b/packages/amis-ui/scss/themes/_common.scss
index bab83ec7a..9f83783c4 100644
--- a/packages/amis-ui/scss/themes/_common.scss
+++ b/packages/amis-ui/scss/themes/_common.scss
@@ -146,3 +146,4 @@
@import '../components/signature';
@import '../components/print';
+@import '../components/verificationCode';
diff --git a/packages/amis-ui/src/components/VerificationCode.tsx b/packages/amis-ui/src/components/VerificationCode.tsx
new file mode 100644
index 000000000..77021b744
--- /dev/null
+++ b/packages/amis-ui/src/components/VerificationCode.tsx
@@ -0,0 +1,316 @@
+/**
+ * @file VerificationCode
+ */
+import React, {
+ ClipboardEvent,
+ useEffect,
+ useMemo,
+ ComponentState,
+ PropsWithoutRef,
+ useRef,
+ useState
+} from 'react';
+import {themeable, ThemeProps} from 'amis-core';
+import InputComponent from './Input';
+import isEqualWith from 'lodash/isEqualWith';
+
+const defaultLength = 6;
+
+/**
+ * VerificationCodeOptions
+ *
+ */
+export interface VerificationCodeOptions {
+ /**
+ * 长度
+ */
+ length?: number;
+ /**
+ * value
+ */
+ value?: string;
+ /**
+ * onChange
+ */
+ onChange?: (value: string) => void;
+ /**
+ * onFinish
+ */
+ onFinish?: (value: string) => void;
+ /**
+ * input list
+ */
+ getInputRefList?: () => HTMLInputElement[];
+}
+
+/**
+ * VerificationCodeReturnType
+ */
+export type VerificationCodeReturnType = {
+ filledValue: VerificationCodeOptions['value'][];
+ value: VerificationCodeOptions['value'];
+ setValue: (v: VerificationCodeOptions['value']) => void;
+ getInputProps: (index: number) => {
+ key: string | number;
+ value: string;
+ onClick: (e: React.MouseEvent) => void;
+ onKeyDown: (e: React.KeyboardEvent) => void;
+ onChange: (v: string | React.ChangeEvent) => void;
+ onPaste: (e: React.ClipboardEvent) => void;
+ };
+};
+
+export interface VerificationCodeProps extends ThemeProps {
+ value?: string;
+ length?: number;
+ /**
+ * 是否是密码模式
+ */
+ masked?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ /**
+ * 分隔符
+ */
+ separator?: (data: {index: number; character: string}) => React.ReactNode;
+ onChange?: (value: string) => void;
+ /**
+ * 输入框都被填充后触发的回调
+ */
+ onFinish?: (value: string) => void;
+}
+
+export function isExist(obj: any): boolean {
+ return obj || obj === 0;
+}
+
+export const Backspace = {
+ key: 'Backspace',
+ code: 8
+};
+
+export function isUndefined(obj: any): obj is undefined {
+ return obj === undefined;
+}
+
+export function usePrevious(value: PropsWithoutRef | ComponentState) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+}
+
+export function useMergeValue(
+ defaultStateValue: T,
+ props?: {
+ value?: T;
+ }
+): [T, React.Dispatch>, T] {
+ const {value} = props || {};
+ const firstRenderRef = useRef(true);
+ const prevPropsValue = usePrevious(value);
+
+ const [stateValue, setStateValue] = useState(
+ !isUndefined(value) ? value : defaultStateValue
+ );
+
+ useEffect(() => {
+ if (firstRenderRef.current) {
+ firstRenderRef.current = false;
+ return;
+ }
+
+ if (value === undefined && prevPropsValue !== value) {
+ setStateValue(value);
+ }
+ }, [value]);
+
+ const mergedValue = isUndefined(value) ? stateValue : value;
+
+ return [mergedValue, setStateValue, stateValue];
+}
+
+export function useVerificationCode(
+ props: VerificationCodeOptions
+): VerificationCodeReturnType {
+ const [value, setValue] = useMergeValue('', props);
+
+ const length = props.length
+ ? +props.length > 0
+ ? +props.length
+ : defaultLength
+ : defaultLength;
+
+ const filledValue: string[] = useMemo(() => {
+ const newVal = value ? String(value).split('') : [];
+ return new Array(length).fill('').map((_, index) => {
+ return isExist(newVal[index]) ? String(newVal[index]) : '';
+ }) as string[];
+ }, [value, length]);
+
+ const focusFirstEmptyInput = () => {
+ const nodeList = props.getInputRefList?.() || [];
+
+ // 焦点的元素
+ if (nodeList?.indexOf(document.activeElement as any) === -1) {
+ return;
+ }
+
+ const index = filledValue.findIndex(x => !x);
+
+ if (index > -1) {
+ const realIndex = Math.min(index, nodeList.length - 1);
+
+ nodeList[realIndex]?.focus?.();
+ }
+ };
+
+ useEffect(() => {
+ focusFirstEmptyInput();
+ if (filledValue.length === length && filledValue.every(item => !!item)) {
+ const nodeList = props.getInputRefList?.() || [];
+ nodeList[nodeList.length - 1]?.blur?.();
+ }
+ }, [JSON.stringify(filledValue)]);
+
+ const tryUpdateValue = (newVal: string) => {
+ if (!isEqualWith(newVal, value)) {
+ setValue(newVal);
+
+ props.onChange?.(newVal);
+
+ if (newVal.length === length) {
+ props.onFinish?.(newVal);
+ }
+ }
+ };
+
+ const handlePaste = (e: ClipboardEvent, index: number) => {
+ e.preventDefault();
+ const clipboardData = e.clipboardData;
+ const text = clipboardData.getData('text');
+ if (text) {
+ tryUpdateValue(
+ filledValue.slice(0, index).concat(text.split('')).join('')
+ );
+ }
+ };
+
+ return {
+ value,
+ filledValue,
+ setValue: tryUpdateValue,
+ getInputProps: index => {
+ const indexVal = String(filledValue[index]);
+ return {
+ key: index,
+ value: indexVal,
+ onClick: e => {
+ e.preventDefault();
+ if (!filledValue[index]) {
+ focusFirstEmptyInput();
+ }
+ },
+ onKeyDown: e => {
+ const keyCode = e.key;
+ if (keyCode === Backspace.key) {
+ if (filledValue[index + 1]) {
+ e.preventDefault();
+ return;
+ }
+ let _index = index;
+ if (!filledValue[index]) {
+ _index -= 1;
+ }
+ const newVal = [...filledValue];
+ newVal[_index] = '';
+ tryUpdateValue(newVal.join(''));
+ }
+ },
+ onChange: (v: string) => {
+ const char = v?.trim() || '';
+ const newVal = [...filledValue];
+ newVal[index] = char.replace(indexVal, '').split('').pop() || '';
+
+ tryUpdateValue(newVal.join(''));
+ },
+ onPaste: (e: ClipboardEvent) => {
+ handlePaste(e, index);
+ }
+ };
+ }
+ };
+}
+
+export function VerificationCodeComponent(baseProps: VerificationCodeProps) {
+ const props = {length: defaultLength, ...baseProps};
+
+ const {
+ separator,
+ length,
+ masked,
+ disabled,
+ readOnly,
+ classnames: cx,
+ onChange,
+ onFinish,
+ value: propsValue,
+ style
+ } = props;
+
+ const focusEleRefList: {current: HTMLInputElement[]} = React.useRef([]);
+
+ const {filledValue, getInputProps} = useVerificationCode({
+ value: propsValue,
+ length,
+ getInputRefList: () => focusEleRefList.current,
+ onChange,
+ onFinish
+ });
+
+ return (
+
+ {filledValue.map((v, index) => {
+ const {
+ onChange: InputChange,
+ onClick,
+ onPaste,
+ onKeyDown,
+ ...restInputProps
+ } = getInputProps(index);
+ return (
+
+
+ (focusEleRefList.current[index] = node)
+ }
+ className={cx({
+ 'is-disabled': !!disabled
+ })}
+ {...restInputProps}
+ onClick={!readOnly ? onClick : undefined}
+ onPaste={!readOnly ? onPaste : undefined}
+ onKeyDown={!readOnly ? onKeyDown : undefined}
+ onChange={
+ !readOnly
+ ? (e: React.ChangeEvent) => {
+ const inputValue = (e.target.value || '').trim();
+
+ InputChange(inputValue);
+ }
+ : undefined
+ }
+ type={masked ? 'password' : 'text'}
+ />
+ {separator?.({index, character: v!})}
+
+ );
+ })}
+
+ );
+}
+
+export default themeable(VerificationCodeComponent);
diff --git a/packages/amis-ui/src/components/index.tsx b/packages/amis-ui/src/components/index.tsx
index bc371dd6b..81a6a50ee 100644
--- a/packages/amis-ui/src/components/index.tsx
+++ b/packages/amis-ui/src/components/index.tsx
@@ -132,6 +132,7 @@ import {CodeMirrorEditor} from './CodeMirror';
import type CodeMirror from 'codemirror';
import OverflowTpl from './OverflowTpl';
import Signature from './Signature';
+import VerificationCode from './VerificationCode';
export {
NotFound,
@@ -269,5 +270,6 @@ export {
CodeMirror,
CodeMirrorEditor,
OverflowTpl,
- Signature
+ Signature,
+ VerificationCode
};
diff --git a/packages/amis/src/Schema.ts b/packages/amis/src/Schema.ts
index edee775a2..6efc0ae8f 100644
--- a/packages/amis/src/Schema.ts
+++ b/packages/amis/src/Schema.ts
@@ -256,6 +256,7 @@ export type SchemaType =
| 'office-viewer'
| 'pdf-viewer'
| 'input-signature'
+ | 'input-verification-code'
// editor 系列
| 'editor'
diff --git a/packages/amis/src/index.tsx b/packages/amis/src/index.tsx
index 4e337be29..6001e8646 100644
--- a/packages/amis/src/index.tsx
+++ b/packages/amis/src/index.tsx
@@ -95,6 +95,7 @@ import './renderers/Form/Group';
import './renderers/Form/InputGroup';
import './renderers/Form/UserSelect';
import './renderers/Form/InputSignature';
+import './renderers/Form/InputVerificationCode';
import './renderers/Grid';
import './renderers/Grid2D';
import './renderers/HBox';
diff --git a/packages/amis/src/renderers/Form/InputVerificationCode.tsx b/packages/amis/src/renderers/Form/InputVerificationCode.tsx
new file mode 100644
index 000000000..f4ef400d0
--- /dev/null
+++ b/packages/amis/src/renderers/Form/InputVerificationCode.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import {
+ autobind,
+ FormControlProps,
+ FormItem,
+ resolveVariableAndFilter
+} from 'amis-core';
+import {FormBaseControlSchema} from '../../Schema';
+import {VerificationCode} from 'amis-ui';
+
+export interface VerificationCodeSchema extends FormBaseControlSchema {
+ value?: string;
+ length?: number;
+ /**
+ * is密码模式
+ */
+ masked?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ /**
+ * 分隔符
+ */
+ separator?: string; // 支持表达式
+}
+
+export interface VerificationCodeProps extends FormControlProps {
+ //
+}
+
+export default class VerificationCodeControl extends React.Component {
+ /**
+ * actions finish
+ * @date 2024-06-04 星期二
+ * @function
+ * @param {}
+ * @return {}
+ */
+ @autobind
+ async onFinish(value: string) {
+ const {dispatchEvent, data} = this.props;
+
+ const rendererEvent = await dispatchEvent(
+ 'finish',
+ {
+ ...data,
+ value
+ },
+ this
+ );
+
+ if (rendererEvent?.prevented) {
+ return;
+ }
+ }
+
+ /**
+ * actions change
+ * @date 2024-06-04 星期二
+ * @function
+ * @param {}
+ * @return {}
+ */
+ @autobind
+ async onChange(value: string) {
+ const {onChange, data, dispatchEvent} = this.props;
+
+ const rendererEvent = await dispatchEvent('change', {
+ ...data,
+ value
+ });
+ if (rendererEvent?.prevented) {
+ return;
+ }
+
+ onChange?.(value);
+ }
+
+ render() {
+ const {separator} = this.props;
+
+ return (
+
+ resolveVariableAndFilter(separator, data)
+ : () => {}
+ }
+ onFinish={this.onFinish}
+ onChange={this.onChange}
+ >
+ );
+ }
+}
+
+@FormItem({
+ type: 'input-verification-code'
+})
+export class VerificationCodeControlRenderer extends VerificationCodeControl {
+ //
+}