mirror of
https://gitee.com/baidu/amis.git
synced 2024-12-02 03:48:13 +08:00
feat: 新增verificationCode验证码渲染器 (#10386)
* feat: 新增verificationCode验证码渲染器和页面设计器 * fix: verificationCode to input-verification-code
This commit is contained in:
parent
a7b142fd50
commit
97244e3106
160
docs/zh-CN/components/form/input-verification-code.md
Normal file
160
docs/zh-CN/components/form/input-verification-code.md
Normal 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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
@ -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)
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
33
packages/amis-ui/scss/components/_verificationCode.scss
Normal file
33
packages/amis-ui/scss/components/_verificationCode.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -146,3 +146,4 @@
|
||||
@import '../components/signature';
|
||||
|
||||
@import '../components/print';
|
||||
@import '../components/verificationCode';
|
||||
|
316
packages/amis-ui/src/components/VerificationCode.tsx
Normal file
316
packages/amis-ui/src/components/VerificationCode.tsx
Normal 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);
|
@ -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
|
||||
};
|
||||
|
@ -256,6 +256,7 @@ export type SchemaType =
|
||||
| 'office-viewer'
|
||||
| 'pdf-viewer'
|
||||
| 'input-signature'
|
||||
| 'input-verification-code'
|
||||
|
||||
// editor 系列
|
||||
| 'editor'
|
||||
|
@ -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';
|
||||
|
102
packages/amis/src/renderers/Form/InputVerificationCode.tsx
Normal file
102
packages/amis/src/renderers/Form/InputVerificationCode.tsx
Normal 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 {
|
||||
//
|
||||
}
|
Loading…
Reference in New Issue
Block a user