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 { + // +}