支持登录验证码

This commit is contained in:
maojindao55
2025-07-18 18:35:41 +08:00
parent c380e7f3d1
commit 85140d8216
12 changed files with 557 additions and 48 deletions

22
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"clsx": "^2.1.1",
"dom-to-image": "^2.6.0",
"dom-to-image-more": "^3.5.0",
"go-captcha-react": "^1.0.4",
"html2canvas": "^1.4.1",
"katex": "^0.16.21",
"lucide-react": "^0.263.1",
@@ -3124,6 +3125,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
@@ -3930,6 +3937,21 @@
"node": "*"
}
},
"node_modules/go-captcha-react": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/go-captcha-react/-/go-captcha-react-1.0.4.tgz",
"integrity": "sha512-1pNUoFqU5bt+tAVwqENTT86c5ih3Usppz7oFo2CPcO13o/Pwj60crudwtYdo8BUOghkYvHGL8gNEMMJFNhO0gQ==",
"license": "MIT",
"dependencies": {
"classnames": "^2.5.1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",

View File

@@ -24,6 +24,7 @@
"clsx": "^2.1.1",
"dom-to-image": "^2.6.0",
"dom-to-image-more": "^3.5.0",
"go-captcha-react": "^1.0.4",
"html2canvas": "^1.4.1",
"katex": "^0.16.21",
"lucide-react": "^0.263.1",

View File

@@ -0,0 +1,33 @@
import React, {useRef} from 'react'
import GoCaptcha from 'go-captcha-react'
// Cache Testing
// import GoCaptcha from '../cache'
import {useClickHandler} from "../hooks/useClickHandler";
function ClickShapeCapt() {
const domRef = useRef(null)
const handler = useClickHandler(domRef, {
getApi: "/api/go-captcha-data/click-shape",
checkApi: "/api/go-captcha-check-data/click-shape"
})
return (
<GoCaptcha.Click
config={{
width: 300,
height: 220,
dotSize: 24,
}}
data={handler.data}
events={{
close: handler.closeEvent,
refresh: handler.refreshEvent,
confirm: handler.confirmEvent,
}}
ref={domRef}
/>
);
}
export default ClickShapeCapt;

View File

@@ -0,0 +1,37 @@
import React, {useRef} from 'react'
import GoCaptcha from 'go-captcha-react'
// Cache Testing
// import GoCaptcha from '../cache'
import {useClickHandler} from "./hooks/useClickHandler";
function ClickTextCapt({onVisibleChange, extraData, onSuccess}) {
const domRef = useRef(null)
const handler = useClickHandler(domRef, {
getApi: "/api/captcha",
checkApi: "/api/captcha/check",
onVisibleChange,
extraData,
onSuccess
})
return (
<GoCaptcha.Click
config={{
width: 300,
height: 220,
dotSize: 24,
}}
data={handler.data}
events={{
close: handler.closeEvent,
refresh: handler.refreshEvent,
confirm: handler.confirmEvent,
}}
ref={domRef}
/>
);
}
export default ClickTextCapt;

View File

@@ -0,0 +1,33 @@
import React, {useRef} from 'react'
import GoCaptcha from 'go-captcha-react'
// Cache Testing
// import GoCaptcha from '../cache'
import {useRotateHandler} from "../hooks/useRotateHandler";
function RotateCapt() {
const domRef = useRef(null)
const handler = useRotateHandler(domRef, {
getApi: "/api/go-captcha-data/rotate-basic",
checkApi: "/api/go-captcha-check-data/rotate-basic"
})
return (
<GoCaptcha.Rotate
config={{
width: 300,
height: 220,
size: 220,
}}
data={handler.data}
events={{
close: handler.closeEvent,
refresh: handler.refreshEvent,
confirm: handler.confirmEvent,
}}
ref={domRef}
/>
);
}
export default RotateCapt;

View File

@@ -0,0 +1,32 @@
import React, {useRef} from 'react'
import GoCaptcha from 'go-captcha-react'
// Cache Testing
// import GoCaptcha from '../cache'
import {useSlideHandler} from "../hooks/useSlideHandler";
function SlideCapt() {
const domRef = useRef(null)
const handler = useSlideHandler(domRef, {
getApi: "/api/go-captcha-data/slide-basic",
checkApi: "/api/go-captcha-check-data/slide-basic"
})
return (
<GoCaptcha.Slide
config={{
width: 300,
height: 220,
}}
data={handler.data}
events={{
close: handler.closeEvent,
refresh: handler.refreshEvent,
confirm: handler.confirmEvent,
}}
ref={domRef}
/>
);
}
export default SlideCapt;

View File

@@ -0,0 +1,32 @@
import React, {useRef} from 'react'
import GoCaptcha from 'go-captcha-react'
// Cache Testing
// import GoCaptcha from '../cache'
import {useSlideHandler} from "../hooks/useSlideHandler";
function SlideRegionCapt() {
const domRef = useRef(null)
const handler = useSlideHandler(domRef, {
getApi: "/api/go-captcha-data/slide-region",
checkApi: "/api/go-captcha-check-data/slide-region"
})
return (
<GoCaptcha.SlideRegion
config={{
width: 300,
height: 220,
}}
data={handler.data}
events={{
close: handler.closeEvent,
refresh: handler.refreshEvent,
confirm: handler.confirmEvent,
}}
ref={domRef}
/>
);
}
export default SlideRegionCapt;

View File

@@ -0,0 +1,116 @@
/**
* @Author Awen
* @Date 2024/05/25
* @Email wengaolng@gmail.com
**/
import { useCallback, useEffect, useState } from "react";
import { toast } from 'sonner';
import { request } from '@/utils/request';
export const useClickHandler = (domRef, config) => {
const [state, setState] = useState({ popoverVisible: false })
const [data, setData] = useState({})
const clickEvent = useCallback(() => {
setState({ ...state, popoverVisible: true })
}, [state])
const visibleChangeEvent = useCallback((visible) => {
setState({ ...state, popoverVisible: visible })
if (config.onVisibleChange) {
config.onVisibleChange(visible)
}
}, [state, config])
const closeEvent = useCallback(() => {
setState({ ...state, popoverVisible: false })
if (config.onVisibleChange) {
config.onVisibleChange(false)
}
}, [state, config])
const requestCaptchaData = useCallback(async () => {
domRef.current.clear && domRef.current.clear()
try {
const response = await request(config.getApi)
const data = await response.json()
if (data && (data['code'] || 0) === 0) {
setData({
image: data['image_base64'] || '',
thumb: data['thumb_base64'] || '',
captKey: data['captcha_key'] || ''
})
} else {
toast.error('获取验证码失败')
}
} catch (e) {
console.warn(e)
toast.error('获取验证码失败')
}
}, [config.getApi, setData])
const refreshEvent = useCallback(() => {
requestCaptchaData()
}, [requestCaptchaData])
const confirmEvent = useCallback(async (dots, clear) => {
const dotArr = []
dots.forEach((item) => {
dotArr.push(item.x, item.y)
})
try {
const formData = new URLSearchParams({
dots: dotArr.join(','),
key: data.captKey || '',
extraData: JSON.stringify(config.extraData),
})
const response = await request(config.checkApi, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
})
const responseData = await response.json()
if ((responseData['code'] || 0) === 0) {
toast.success(responseData.message)
setState({ ...state, popoverVisible: false, type: "success" })
if (config.onVisibleChange) {
config.onVisibleChange(false)
}
if (config.onSuccess) {
config.onSuccess()
}
} else {
toast.error('验证码验证失败')
setState({ ...state, type: "error" })
}
setTimeout(() => {
requestCaptchaData()
}, 1000)
} catch (e) {
console.warn(e)
toast.error('验证码验证失败')
}
}, [data, state, setState, config.checkApi, requestCaptchaData])
useEffect(() => {
requestCaptchaData()
}, [requestCaptchaData])
return {
state,
data,
visibleChangeEvent,
clickEvent,
closeEvent,
refreshEvent,
confirmEvent,
}
}

View File

@@ -0,0 +1,96 @@
/**
* @Author Awen
* @Date 2024/05/25
* @Email wengaolng@gmail.com
**/
import {useCallback, useEffect, useRef, useState} from "react";
import Lodash from "lodash";
import Axios from 'axios'
import { Message } from '@arco-design/web-react';
import Qs from 'qs'
export const useRotateHandler = (domRef, config) => {
const [state, setState] = useState({})
const [data, setData] = useState({})
const clickEvent = useCallback(() => {
setState({...state, popoverVisible: true})
}, [state])
const visibleChangeEvent = useCallback((visible) => {
setState({...state, popoverVisible: visible})
}, [state])
const closeEvent = useCallback(() => {
setState({...state, popoverVisible: false})
}, [state])
const requestCaptchaData = useCallback(() => {
domRef.current.clear && domRef.current.clear()
Axios({
method: 'get',
url: config.getApi,
}).then((response)=>{
const {data = {}} = response;
if (!Lodash.isEmpty(data) && (data['code'] || 0) === 0) {
setData({
image: data['image_base64'] || '',
thumb: data['thumb_base64'] || '',
thumbSize: data['thumb_size'] || 0,
captKey: data['captcha_key'] || '',
})
} else {
Message.warning(`failed get captcha data`)
}
}).catch((e)=>{
console.warn(e)
})
}, [setData, config.getApi])
const refreshEvent = useCallback(() => {
requestCaptchaData()
}, [requestCaptchaData])
const confirmEvent = useCallback((angle, clear) => {
Axios({
method: 'post',
url: config.checkApi,
data: Qs.stringify({
angle: angle,
key: data.captKey || ''
}),
}).then((response)=>{
const {data = {}} = response;
if ((data['code'] || 0) === 0) {
Message.success(`check captcha data success`)
setState({...state, popoverVisible: false, type: "success"})
} else {
Message.warning(`failed check captcha data`)
setState({...state, type: "error"})
}
setTimeout(() => {
requestCaptchaData()
}, 1000)
}).catch((e)=>{
console.warn(e)
})
}, [data, state, setState, config.checkApi, requestCaptchaData])
useEffect(() => {
// if (state.popoverVisible) {
requestCaptchaData()
// }
}, [])
return {
state,
data,
visibleChangeEvent,
clickEvent,
closeEvent,
refreshEvent,
confirmEvent,
}
}

View File

@@ -0,0 +1,99 @@
/**
* @Author Awen
* @Date 2024/05/25
* @Email wengaolng@gmail.com
**/
import {useCallback, useEffect, useRef, useState} from "react";
import Lodash from "lodash";
import Axios from 'axios'
import { Message } from '@arco-design/web-react';
import Qs from 'qs'
export const useSlideHandler = (domRef, config) => {
const [state, setState] = useState({})
const [data, setData] = useState({})
const clickEvent = useCallback(() => {
setState({...state, popoverVisible: true})
}, [state])
const visibleChangeEvent = useCallback((visible) => {
setState({...state, popoverVisible: visible})
}, [state])
const closeEvent = useCallback(() => {
setState({...state, popoverVisible: false})
}, [state])
const requestCaptchaData = useCallback(() => {
domRef.current.clear && domRef.current.clear()
Axios({
method: 'get',
url: config.getApi,
}).then((response)=>{
const {data = {}} = response;
if (!Lodash.isEmpty(data) && (data['code'] || 0) === 0) {
setData({
image: data['image_base64'] || '',
thumb: data['tile_base64'] || '',
captKey: data['captcha_key'] || '',
thumbX: data['tile_x'] || 0,
thumbY: data['tile_y'] || 0,
thumbWidth: data['tile_width'] || 0,
thumbHeight: data['tile_height'] || 0,
})
} else {
Message.warning(`failed get captcha data`)
}
}).catch((e)=>{
console.warn(e)
})
}, [setData, config.getApi])
const refreshEvent = useCallback(() => {
requestCaptchaData()
}, [requestCaptchaData])
const confirmEvent = useCallback((point, clear) => {
Axios({
method: 'post',
url: config.checkApi,
data: Qs.stringify({
point: [point.x, point.y].join(','),
key: data.captKey || ''
}),
}).then((response)=>{
const {data = {}} = response;
if ((data['code'] || 0) === 0) {
Message.success(`check captcha data success`)
setState({...state, popoverVisible: false, type: "success"})
} else {
Message.warning(`failed check captcha data`)
setState({...state, type: "error"})
}
setTimeout(() => {
requestCaptchaData()
}, 1000)
}).catch((e)=>{
console.warn(e)
})
}, [data, state, setState, config.checkApi, requestCaptchaData])
useEffect(() => {
// if (state.popoverVisible) {
requestCaptchaData()
// }
}, [])
return {
state,
data,
visibleChangeEvent,
clickEvent,
closeEvent,
refreshEvent,
confirmEvent,
}
}

View File

@@ -22,6 +22,7 @@ import { MembersManagement } from '@/pages/chat/components/MembersManagement';
import Sidebar from './Sidebar';
import { AdBanner, AdBannerMobile } from './AdSection';
import { useUserStore } from '@/store/userStore';
import { useIsMobile } from '@/hooks/use-mobile';
import { getAvatarData } from '@/utils/avatar';
@@ -55,6 +56,7 @@ const KaTeXStyle = () => (
const ChatUI = () => {
const userStore = useUserStore();
const isMobile = useIsMobile();
//获取url参数
const urlParams = new URLSearchParams(window.location.search);
@@ -77,7 +79,14 @@ const ChatUI = () => {
const [isTyping, setIsTyping] = useState(false);
const [mutedUsers, setMutedUsers] = useState<string[]>([]);
const [showPoster, setShowPoster] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false); // 默认关闭,稍后根据设备类型设置
// 根据设备类型设置侧边栏默认状态
useEffect(() => {
if (isMobile !== undefined) {
setSidebarOpen(!isMobile); // 手机端关闭PC端开启
}
}, [isMobile]);
// 2. 所有的 useRef 声明
const currentMessageRef = useRef<number | null>(null);

View File

@@ -3,6 +3,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from 'sonner';
import { request } from '@/utils/request';
import { useIsMobile } from '@/hooks/use-mobile';
import ClickTextCapt from "@/components/goCaptcha/ClickTextCapt.jsx";
interface PhoneLoginProps {
onLogin: (phone: string, code: string) => void;
@@ -13,33 +15,14 @@ const PhoneLogin: React.FC<PhoneLoginProps> = ({ handleLoginSuccess }) => {
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showCaptcha, setShowCaptcha] = useState(false); // 新增弹窗状态
const isMobile = useIsMobile();
// 获取备案号配置
const icpNumber = (window as any).APP_CONFIG?.ICP_NUMBER;
// 发送验证码
const handleSendCode = async () => {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
toast.error('请输入正确的手机号');
return;
}
setIsLoading(true);
try {
const response = await request(`/api/sendcode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone }),
});
if (!response.ok) {
throw new Error('发送验证码失败');
}
toast.success('验证码已发送');
// 发送验证码成功后倒计时
const handleSendCodeSuccess = () => {
// 开始倒计时
setCountdown(60);
const timer = setInterval(() => {
@@ -51,15 +34,23 @@ const PhoneLogin: React.FC<PhoneLoginProps> = ({ handleLoginSuccess }) => {
return prev - 1;
});
}, 1000);
} catch (error) {
console.error('发送验证码失败:', error);
toast.error('发送验证码失败,请重试');
} finally {
setIsLoading(false);
}
// 点击发送验证码按钮
const handleSendCode = async () => {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
toast.error('请输入正确的手机号');
return;
}
setShowCaptcha(true); // 弹出图形验证码弹窗
};
// 图形验证码通过后的回调
// const handleCaptchaSuccess = async () => {
// setShowCaptcha(false);
// await realSendCode();
// };
// 提交登录
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -91,55 +82,56 @@ const PhoneLogin: React.FC<PhoneLoginProps> = ({ handleLoginSuccess }) => {
return (
<div className="fixed inset-0 bg-white flex items-center justify-center">
<div className="w-full max-w-md p-8">
<div className={`w-full ${isMobile ? 'max-w-sm px-6' : 'max-w-md px-8'} ${isMobile ? 'py-6' : 'py-8'}`}>
{/* Logo */}
<div className="flex items-center justify-center mb-4">
<span style={{fontFamily: 'Audiowide, system-ui', color: '#ff6600'}} className="text-3xl ml-2">botgroup.chat</span>
<div className="flex items-center justify-center mb-6">
<span
style={{fontFamily: 'Audiowide, system-ui', color: '#ff6600'}}
className={`${isMobile ? 'text-2xl' : 'text-3xl'} ml-2`}
>
botgroup.chat
</span>
</div>
<div className="text-gray-500 mb-4 text-center">
<div className={`text-gray-500 ${isMobile ? 'mb-6' : 'mb-4'} text-center ${isMobile ? 'text-sm' : 'text-base'}`}>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className={`${isMobile ? 'space-y-4' : 'space-y-6'}`}>
<div>
<div className="flex items-center border rounded-lg p-3 h-[46px] focus-within:border-[#ff6600]">
<span className="text-gray-400 mr-2">+86</span>
<div className={`flex items-center border rounded-lg ${isMobile ? 'p-2.5' : 'p-3'} ${isMobile ? 'h-[42px]' : 'h-[46px]'} focus-within:border-[#ff6600]`}>
<span className={`text-gray-400 mr-2 ${isMobile ? 'text-sm' : 'text-base'}`}>+86</span>
<Input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={11}
className="border-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none p-0"
className={`border-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none p-0 ${isMobile ? 'text-sm' : 'text-base'}`}
/>
</div>
</div>
<div>
<div className="flex gap-3">
<div className={`flex ${isMobile ? 'gap-2' : 'gap-3'}`}>
<Input
type="text"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="border rounded-lg p-3 h-[46px] focus:border-[#ff6600] focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none"
className={`border rounded-lg ${isMobile ? 'p-2.5' : 'p-3'} ${isMobile ? 'h-[42px]' : 'h-[46px]'} focus:border-[#ff6600] focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none ${isMobile ? 'text-sm' : 'text-base'}`}
/>
<Button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
className="bg-white text-[#ff6600] border border-[#ff6600] hover:bg-[#ff6600] hover:text-white rounded-lg px-6 h-[46px]"
className={`bg-white text-[#ff6600] border border-[#ff6600] hover:bg-[#ff6600] hover:text-white rounded-lg ${isMobile ? 'px-3 h-[42px] text-xs' : 'px-6 h-[46px] text-sm'} whitespace-nowrap`}
>
{countdown > 0 ? `${countdown}秒后重试` : '发送验证码'}
</Button>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#ff6600] hover:bg-[#e65c00] text-white rounded-lg py-3"
className={`w-full bg-[#ff6600] hover:bg-[#e65c00] text-white rounded-lg ${isMobile ? 'py-2.5 text-sm' : 'py-3 text-base'}`}
disabled={isLoading}
>
{isLoading ? (
@@ -147,10 +139,17 @@ const PhoneLogin: React.FC<PhoneLoginProps> = ({ handleLoginSuccess }) => {
) : '登录'}
</Button>
</form>
{/* 弹窗形式的图形验证码 */}
{showCaptcha && (
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
<div className={`${isMobile ? 'mx-4' : 'mx-0'}`}>
<ClickTextCapt onVisibleChange={setShowCaptcha} onSuccess={handleSendCodeSuccess} extraData={{phone}} />
</div>
</div>
)}
{/* 备案号显示 */}
{icpNumber && (
<div className="text-center mt-8 text-xs text-gray-400">
<div className={`text-center ${isMobile ? 'mt-6' : 'mt-8'} text-xs text-gray-400`}>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">{icpNumber}</a>
</div>
)}