feat: 新增verificationCode验证码渲染器 (#10386)

* feat: 新增verificationCode验证码渲染器和页面设计器

* fix: verificationCode to input-verification-code
This commit is contained in:
lq 2024-06-19 19:41:10 +08:00 committed by GitHub
parent a7b142fd50
commit 97244e3106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 626 additions and 1 deletions

View File

@ -0,0 +1,160 @@
---
title: 验证码输入 InputVerificationCode
description:
type: 0
group: null
menuName: InputVerificationCode 验证码
icon:
order: 63
---
注意 InputVerificationCode, 可通过<b>粘贴</b>完成填充数据。
## 基本用法
基本用法。
```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 : '-'}",
}
]
}
```
## 状态
<p>指定 disabled = true可开启禁用模式。</p>
指定 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}"
}
}
]
}
}
}
```

View File

@ -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)
)
}
]
},

View File

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

View File

@ -146,3 +146,4 @@
@import '../components/signature';
@import '../components/print';
@import '../components/verificationCode';

View File

@ -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<HTMLInputElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onChange: (v: string | React.ChangeEvent<HTMLInputElement>) => void;
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => 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<T>(value: PropsWithoutRef<T> | ComponentState) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export function useMergeValue<T>(
defaultStateValue: T,
props?: {
value?: T;
}
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
const {value} = props || {};
const firstRenderRef = useRef(true);
const prevPropsValue = usePrevious(value);
const [stateValue, setStateValue] = useState<T>(
!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 (
<div className={cx('Verification-code')} style={style}>
{filledValue.map((v, index) => {
const {
onChange: InputChange,
onClick,
onPaste,
onKeyDown,
...restInputProps
} = getInputProps(index);
return (
<React.Fragment key={index}>
<InputComponent
disabled={disabled}
readOnly={readOnly}
ref={(node: HTMLInputElement) =>
(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<HTMLInputElement>) => {
const inputValue = (e.target.value || '').trim();
InputChange(inputValue);
}
: undefined
}
type={masked ? 'password' : 'text'}
/>
{separator?.({index, character: v!})}
</React.Fragment>
);
})}
</div>
);
}
export default themeable(VerificationCodeComponent);

View File

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

View File

@ -256,6 +256,7 @@ export type SchemaType =
| 'office-viewer'
| 'pdf-viewer'
| 'input-signature'
| 'input-verification-code'
// editor 系列
| 'editor'

View File

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

View File

@ -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<VerificationCodeProps> {
/**
* 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 (
<VerificationCode
{...this.props}
separator={
typeof separator === 'string'
? (data: {index: number; character: string}) =>
resolveVariableAndFilter(separator, data)
: () => {}
}
onFinish={this.onFinish}
onChange={this.onChange}
></VerificationCode>
);
}
}
@FormItem({
type: 'input-verification-code'
})
export class VerificationCodeControlRenderer extends VerificationCodeControl {
//
}