ERPTurbo_Admin/packages/app-operation/src/components/Captcha/index.tsx
shenyifei 1429319b01 refactor(components): 调整组件导入路径并新增验证码组件
- 将多个组件中的 @chageable/components 导入改为从 @/components 导入
- 在 BoxBrandList.tsx、CostList.tsx、ProductDataList.tsx 等文件中更新 ProFormBizSelect 相关组件导入
- 在 CaptchaModal/index.tsx 中将 Captcha 组件导入从 @chageable/components 改为 @/components
- 在 ChannelList.tsx、CompanyList.tsx、EmployeeList.tsx 等文件中更新 ProFormUploadMaterial 导入
- 在 MaterialList.tsx 中更新 ProFormBizTreeSelect 和 ProFormUploadOss 导入
- 在 MenuList.tsx、OrderCostList.tsx 中更新 ProFormBizSelect 相关组件导入
- 在 DealerModal.tsx、MaterialModal.tsx、OrderModal.tsx 等文件中更新 SelectModal 导入
- 在 app.tsx 中将 LeftMenu 导入从 @chageable/components 改为 @/components
- 新增 UploadMaterial 组件并添加到 components/index.ts 导出
- 在 Captcha 组件中添加滑动验证码功能,包含样式和交互逻辑
- 在 companyPaymentAccount 工具函数中添加 branchName 字段支持
2026-01-07 00:12:26 +08:00

407 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
CheckCircleOutlined,
CloseCircleOutlined,
DoubleRightOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Modal, Skeleton, message } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import './index.less';
import { checkCaptchaMock, getPictureMock } from './mock';
import { AJCaptchaIconProps, AJCaptchaSliderProps, CaptchaRes } from './typing';
import { aesEncrypt, setStyle, uuid } from './utils';
const Scale = {
default: 1,
big: 1.4,
large: 1.8,
};
const AJCaptchaIcon = (props: { icon: AJCaptchaIconProps }) => {
const iconStyle: React.CSSProperties = {
fontSize: '18px',
color: '#999',
};
switch (props.icon) {
case 'right':
return <DoubleRightOutlined style={iconStyle} />;
case 'fail':
return <CloseCircleOutlined style={{ ...iconStyle, color: '#ff4d4f' }} />;
case 'loading':
return <LoadingOutlined style={iconStyle} />;
case 'check':
return <CheckCircleOutlined style={{ ...iconStyle, color: '#52c41a' }} />;
}
};
const AJCaptchaSlider: React.FC<AJCaptchaSliderProps> = ({
show = false,
title,
tips,
refreshText,
size = 'default',
vSpace = 18, // 图片与滑块的距离单位px
sliderBlockWidth = 45, // 滑块宽度45单位px
padding = 16, // 弹框内边距 单位px
hide,
onSuccess,
setSize = {
imgWidth: 310, // 图片宽度
imgHeight: 155, // 图片高度
barHeight: 36, // 滑块框高度
},
getPicture = getPictureMock,
checkCaptcha = checkCaptchaMock,
}) => {
const scale = Scale[size];
const blockWidth = sliderBlockWidth * scale;
const imgWidth = setSize.imgWidth * scale;
const imgHeight = setSize.imgHeight * scale;
const isSupportTouch = 'ontouchstart' in window;
const events = isSupportTouch
? {
start: 'touchstart',
move: 'touchmove',
end: 'touchend',
}
: {
start: 'mousedown',
move: 'mousemove',
end: 'mouseup',
};
const [isLoading, setLoading] = useState<boolean>(false); // 是否加载
const [response, setResponse] = useState<CaptchaRes | null>(null); // token、密钥、图片等数据
const [icon, setIcon] = useState<AJCaptchaIconProps>('loading'); // 滑块icon
const [showTips, setTips] = useState<boolean>(true); // 是否展示提示文案
const [blockPressed, setPressed] = useState<boolean>(false); // 滑块是否被按下
const barAreaRef = useRef({
barAreaLeft: 0,
barAreaOffsetWidth: 0,
});
const leftBarRef = useRef<HTMLDivElement>(null);
const isEnd = useRef<boolean>(false);
const status = useRef<boolean>(false);
const closeBox = () => {
setResponse(null);
hide?.();
};
const getData = () => {
setLoading(true);
setIcon('right');
getPicture()
.then((res) => {
setResponse(res);
})
.finally(() => setLoading(false));
};
/**
* 刷新数据和界面状态的函数
* 此函数主要用于在不加载中的情况下,重新获取数据并重置鼠标状态、结束状态、提示信息和布局宽度
* 它确保在界面交互过程中,用户界面始终保持一致和响应性
*/
const refresh = () => {
// 检查数据加载状态,如果正在加载,则不执行后续操作
if (isLoading) return;
// 重新获取数据
getData();
// 重置状态,准备下一次交互
isEnd.current = false;
status.current = false;
// 设置提示信息,指导用户进行下一步操作
setTips(true);
};
/**
* 处理滑动事件的方法,用于更新滑动块的位置和相关的状态
* @param e React的鼠标点击事件或触摸事件对象
*/
const move = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
// 如果当前状态不允许滑动或滑动已结束,则直接返回,不执行后续操作
if (!status.current || isEnd.current) return;
// 根据事件类型触摸或鼠标获取滑动的x坐标
const x = 'touches' in e ? e.touches[0].pageX : e.clientX;
// 计算滑动块可以移动的最大左边距
const maxLeft = barAreaRef.current.barAreaOffsetWidth - blockWidth;
// 根据滑动位置计算滑动块的实际左边距,确保它在允许的范围内
const moveBlockLeft = Math.max(
0,
Math.min(x - barAreaRef.current.barAreaLeft - blockWidth / 2, maxLeft),
);
// 拖动后小方块的left值
const left = Math.max(0, moveBlockLeft);
// 计算对应leftBar的width值
const width = `${left + blockWidth}px`;
// 设置提示信息为空,表示滑动操作正常进行,无错误或额外信息需要展示
setTips(false);
// 设置leftBar的width值
setStyle(leftBarRef.current, { width });
};
const end = () => {
document.removeEventListener(events.move, move as any);
document.removeEventListener(events.end, end);
document.removeEventListener('touchcancel', end);
// 判断是否重合
if (status.current && !isEnd.current) {
setIcon('loading');
const leftBarWidth = parseInt(
(leftBarRef.current?.style.width || '').replace('px', ''),
);
const rawPointJson = JSON.stringify({
x: (leftBarWidth - blockWidth) / scale, // 计算x轴偏移量
y: 5.0,
});
const data = {
captchaType: 'blockPuzzle',
pointJson: response?.secretKey
? aesEncrypt(rawPointJson, response?.secretKey)
: rawPointJson,
token: response?.token || '',
clientUid: localStorage.getItem('slider')!,
ts: Date.now(),
};
checkCaptcha(data)
.then((res) => {
isEnd.current = true;
if (res.token) {
setIcon('check');
message.success('验证成功!');
setTimeout(() => {
const params = `${res.token}---${rawPointJson}`;
onSuccess(aesEncrypt(params, response?.secretKey));
closeBox();
}, 1000);
} else {
setIcon('fail');
message.error('验证失败!');
setTimeout(() => {
refresh();
}, 800);
}
})
.catch(() => {
isEnd.current = true;
setIcon('fail');
message.error('验证失败!');
setTimeout(() => {
refresh();
}, 800);
});
status.current = false;
setPressed(false);
}
};
useEffect(() => {
if (!localStorage.getItem('slider'))
localStorage.setItem('slider', `slider-${uuid()}`);
// 清理函数
return () => {
if (localStorage.getItem('slider')) localStorage.removeItem('slider');
document.removeEventListener(events.move, move as any);
document.removeEventListener(events.end, end);
document.removeEventListener('touchcancel', end);
};
}, []);
useEffect(() => {
if (show) refresh();
}, [show]);
/**
* 设置栏区域的左边界和宽度
* 此函数通过计算给定HTML元素的位置和尺寸来更新栏区域的左边界和宽度
* @param event HTMLDivElement类型代表触发事件的HTML元素它用于获取栏区域的位置和宽度信息
*/
const setBarArea = (event: HTMLDivElement | null) => {
if (!event) return;
// 获取栏区域左边界的坐标
const newBarAreaLeft = event.getBoundingClientRect().left;
// 获取栏区域的宽度
const newBarAreaOffsetWidth = event.offsetWidth;
// 更新状态
barAreaRef.current = {
barAreaLeft: newBarAreaLeft, // 记录栏区域的左边界
barAreaOffsetWidth: newBarAreaOffsetWidth, // 记录栏区域的宽度
};
};
const start = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
if (isEnd.current) return;
status.current = true;
setPressed(true);
document.addEventListener(events.move, move as any);
document.addEventListener(events.end, end);
document.addEventListener('touchcancel', end);
e.stopPropagation();
};
return (
<Modal
title={title}
className="captcha-modal"
centered
open={show}
maskClosable={false}
width={imgWidth + 2 * padding}
styles={{
content: {
padding: `16px ${padding}px 10px`,
userSelect: 'none',
},
}}
footer={null}
onCancel={closeBox}
>
<div className="verifybox">
{isLoading ? (
<div
style={{
width: imgWidth,
}}
>
<div
className="verify-img-out"
style={{ height: imgHeight + vSpace }}
>
<Skeleton.Image
active
style={{ height: imgHeight, width: imgWidth }}
/>
</div>
<Skeleton.Node
active
style={{ height: setSize.barHeight, width: imgWidth }}
/>
</div>
) : (
<div>
<div
className="verify-img-out"
style={{ height: imgHeight + vSpace }}
>
<div
className="verify-img-panel"
style={{
width: imgWidth,
height: imgHeight,
}}
>
{response?.originalImageBase64 && (
<img
src={
'data:image/png;base64,' + response?.originalImageBase64
}
alt="captcha-image"
draggable={false}
className="verify-img"
/>
)}
</div>
</div>
<div
className="verify-bar-area"
style={{
width: imgWidth,
height: setSize.barHeight,
}}
ref={(bar) => setBarArea(bar)}
>
<div
className="verify-msg"
style={{
lineHeight: setSize.barHeight + 'px',
marginLeft: blockWidth + 'px',
width: imgWidth - blockWidth + 'px',
display: showTips ? 'block' : 'none',
}}
>
{tips}
</div>
<div
className="verify-left-bar"
ref={leftBarRef}
style={{
width: blockWidth,
height: setSize.barHeight,
touchAction: 'pan-y',
}}
>
<div
className="verify-move-block"
onMouseDown={start}
onTouchStart={start}
style={{
width: blockWidth,
backgroundColor: blockPressed ? '#f2f2f2' : '#fff',
cursor: blockPressed ? 'grab' : 'pointer',
height: setSize.barHeight - 2,
}}
>
{<AJCaptchaIcon icon={icon} />}
<div
className="verify-sub-block"
style={{
width: blockWidth,
height: imgHeight,
top: `-${imgHeight + vSpace}px`,
backgroundSize: `${imgWidth} ${imgHeight}`,
}}
>
{response?.jigsawImageBase64 && (
<img
src={
'data:image/png;base64,' + response?.jigsawImageBase64
}
alt="blockImage"
className="verify-img"
/>
)}
</div>
</div>
</div>
</div>
</div>
)}
<div className="verify-very-bottom">
<div className="verify-refresh" onClick={refresh}>
<ReloadOutlined spin={isLoading} />
<span className="verify-refresh-text">{refreshText}</span>
</div>
</div>
</div>
</Modal>
);
};
export default AJCaptchaSlider;