diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 151a8c399..e5d78bb3d 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -9,6 +9,11 @@ export interface FormDataType { [prop: string]: FormValue; } +export interface FieldError { + error_field: string; + error_msg: string; +} + export interface Paging { page: number; page_size?: number; @@ -52,7 +57,7 @@ export interface TagInfo extends TagBase { main_tag_slug_name?: string; excerpt?; } -export interface QuestionParams { +export interface QuestionParams extends ImgCodeReq{ title: string; url_title?: string; content: string; @@ -68,7 +73,7 @@ export interface ListResult { list: T[]; } -export interface AnswerParams { +export interface AnswerParams extends ImgCodeReq { content: string; html: string; question_id: string; @@ -169,10 +174,29 @@ export interface PasswordResetReq extends ImgCodeReq { e_mail: string; } -export interface CheckImgReq { - action: 'login' | 'e_mail' | 'find_pass' | 'modify_pass'; +export interface PasswordReplaceReq extends ImgCodeReq { + code: string; + pass: string; +} + +export interface CaptchaReq extends ImgCodeReq { + verify: ImgCodeRes['verify']; } +export type CaptchaKey = + | 'email' + | 'password' + | 'edit_userinfo' + | 'question' + | 'answer' + | 'comment' + | 'edit' + | 'invitation_answer' + | 'search' + | 'report' + | 'delete' + | 'vote'; + export interface SetNoticeReq { notice_switch: boolean; } @@ -222,7 +246,7 @@ export interface AnswerItem { [prop: string]: any; } -export interface PostAnswerReq { +export interface PostAnswerReq extends ImgCodeReq { content: string; html?: string; question_id: string; @@ -425,7 +449,7 @@ export interface FollowParams { /** * @description search request params */ -export interface SearchParams { +export interface SearchParams extends ImgCodeReq { q: string; order: string; page: number; diff --git a/ui/src/components/Actions/index.tsx b/ui/src/components/Actions/index.tsx index 554f57c30..dfa2102bd 100644 --- a/ui/src/components/Actions/index.tsx +++ b/ui/src/components/Actions/index.tsx @@ -6,9 +6,10 @@ import classNames from 'classnames'; import { Icon } from '@/components'; import { loggedUserInfoStore } from '@/stores'; -import { useToast } from '@/hooks'; +import { useToast, useCaptchaModal } from '@/hooks'; import { tryNormalLogged } from '@/utils/guard'; import { bookmark, postVote } from '@/services'; +import * as Types from '@/common/interface'; interface Props { className?: string; @@ -36,6 +37,8 @@ const Index: FC = ({ className, data, source }) => { const { username = '' } = loggedUserInfoStore((state) => state.user); const toast = useToast(); const { t } = useTranslation(); + const vCaptcha = useCaptchaModal('vote'); + useEffect(() => { if (data) { setVotes(data.votesCount); @@ -61,27 +64,39 @@ const Index: FC = ({ className, data, source }) => { return; } const isCancel = (type === 'up' && like) || (type === 'down' && hate); - postVote( - { - object_id: data?.id, - is_cancel: isCancel, - }, - type, - ) - .then((res) => { - setVotes(res.votes); - setLike(res.vote_status === 'vote_up'); - setHated(res.vote_status === 'vote_down'); - }) - .catch((err) => { - const errMsg = err?.value; - if (errMsg) { - toast.onShow({ - msg: errMsg, - variant: 'danger', - }); - } - }); + vCaptcha.check(() => { + const imgCode: Types.ImgCodeReq = { + captcha_id: undefined, + captcha_code: undefined, + }; + vCaptcha.resolveCaptchaReq(imgCode); + postVote( + { + object_id: data?.id, + is_cancel: isCancel, + ...imgCode, + }, + type, + ) + .then(async (res) => { + await vCaptcha.close(); + setVotes(res.votes); + setLike(res.vote_status === 'vote_up'); + setHated(res.vote_status === 'vote_down'); + }) + .catch((err) => { + if (err?.isError) { + vCaptcha.handleCaptchaError(err.list); + } + const errMsg = err?.value; + if (errMsg) { + toast.onShow({ + msg: errMsg, + variant: 'danger', + }); + } + }); + }); }; const handleBookmark = () => { diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index 66a5229c9..da3f5d84e 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -8,7 +8,7 @@ import { unionBy } from 'lodash'; import * as Types from '@/common/interface'; import { Modal } from '@/components'; -import { usePageUsers, useReportModal } from '@/hooks'; +import { usePageUsers, useReportModal, useCaptchaModal } from '@/hooks'; import { matchedUsers, parseUserInfo, @@ -43,6 +43,11 @@ const Comment = ({ objectId, mode, commentId }) => { const reportModal = useReportModal(); + const addCaptcha = useCaptchaModal('comment'); + const editCaptcha = useCaptchaModal('edit'); + const dCaptcha = useCaptchaModal('delete'); + const vCaptcha = useCaptchaModal('vote'); + const { t } = useTranslation('translation', { keyPrefix: 'comment' }); const scrollCallback = useCallback((el, co) => { if (pageIndex === 0 && co.comment_id === commentId) { @@ -120,43 +125,67 @@ const Comment = ({ objectId, mode, commentId }) => { }; if (item.type === 'edit') { - return updateComment({ - ...params, - comment_id: item.comment_id, - }).then((res) => { - setComments( - comments.map((comment) => { - if (comment.comment_id === item.comment_id) { - comment.showEdit = false; - comment.parsed_text = res.parsed_text; - comment.original_text = res.original_text; + return editCaptcha.check(() => { + const up = { + ...params, + comment_id: item.comment_id, + captcha_code: undefined, + captcha_id: undefined, + }; + editCaptcha.resolveCaptchaReq(up); + + return updateComment(up) + .then(async (res) => { + await editCaptcha.close(); + setComments( + comments.map((comment) => { + if (comment.comment_id === item.comment_id) { + comment.showEdit = false; + comment.parsed_text = res.parsed_text; + comment.original_text = res.original_text; + } + return comment; + }), + ); + }) + .catch((err) => { + if (err.isError) { + editCaptcha.handleCaptchaError(err.list); } - return comment; - }), - ); + }); }); } - return addComment(params).then((res) => { - if (item.type === 'reply') { - const index = comments.findIndex( - (comment) => comment.comment_id === item.comment_id, - ); - comments[index].showReply = false; - comments.splice(index + 1, 0, res); - setComments([...comments]); - } else { - setComments([ - ...comments.map((comment) => { - if (comment.comment_id === item.comment_id) { - comment.showReply = false; - } - return comment; - }), - res, - ]); - } - setVisibleComment(false); + return addCaptcha.check(() => { + const req = { + ...params, + captcha_code: undefined, + captcha_id: undefined, + }; + addCaptcha.resolveCaptchaReq(req); + + return addComment(req).then((res) => { + if (item.type === 'reply') { + const index = comments.findIndex( + (comment) => comment.comment_id === item.comment_id, + ); + comments[index].showReply = false; + comments.splice(index + 1, 0, res); + setComments([...comments]); + } else { + setComments([ + ...comments.map((comment) => { + if (comment.comment_id === item.comment_id) { + comment.showReply = false; + } + return comment; + }), + res, + ]); + } + + setVisibleComment(false); + }); }); }; @@ -167,11 +196,23 @@ const Comment = ({ objectId, mode, commentId }) => { confirmBtnVariant: 'danger', confirmText: t('delete', { keyPrefix: 'btns' }), onConfirm: () => { - deleteComment(id).then(() => { - if (pageIndex === 0) { - mutate(); - } - setComments(comments.filter((item) => item.comment_id !== id)); + dCaptcha.check(() => { + const imgCode = { captcha_id: undefined, captcha_code: undefined }; + dCaptcha.resolveCaptchaReq(imgCode); + + deleteComment(id, imgCode) + .then(async () => { + await dCaptcha.close(); + if (pageIndex === 0) { + mutate(); + } + setComments(comments.filter((item) => item.comment_id !== id)); + }) + .catch((ex) => { + if (ex.isError) { + dCaptcha.handleCaptchaError(ex.list); + } + }); }); }, }); @@ -182,24 +223,40 @@ const Comment = ({ objectId, mode, commentId }) => { return; } - postVote( - { - object_id: id, - is_cancel, - }, - 'up', - ).then(() => { - setComments( - comments.map((item) => { - if (item.comment_id === id) { - item.vote_count = is_cancel - ? item.vote_count - 1 - : item.vote_count + 1; - item.is_vote = !is_cancel; + vCaptcha.check(() => { + const imgCode: Types.ImgCodeReq = { + captcha_id: undefined, + captcha_code: undefined, + }; + vCaptcha.resolveCaptchaReq(imgCode); + + postVote( + { + object_id: id, + is_cancel, + ...imgCode, + }, + 'up', + ) + .then(async () => { + await vCaptcha.close(); + setComments( + comments.map((item) => { + if (item.comment_id === id) { + item.vote_count = is_cancel + ? item.vote_count - 1 + : item.vote_count + 1; + item.is_vote = !is_cancel; + } + return item; + }), + ); + }) + .catch((ex) => { + if (ex.isError) { + vCaptcha.handleCaptchaError(ex.list); } - return item; - }), - ); + }); }); }; diff --git a/ui/src/components/Operate/index.tsx b/ui/src/components/Operate/index.tsx index 03e150f6d..d322b9d1d 100644 --- a/ui/src/components/Operate/index.tsx +++ b/ui/src/components/Operate/index.tsx @@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Modal } from '@/components'; -import { useReportModal, useToast } from '@/hooks'; +import { useReportModal, useToast, useCaptchaModal } from '@/hooks'; import { QuestionOperationReq } from '@/common/interface'; import Share from '../Share'; import { @@ -44,6 +44,7 @@ const Index: FC = ({ const toast = useToast(); const navigate = useNavigate(); const reportModal = useReportModal(); + const dCaptcha = useCaptchaModal('delete'); const refreshQuestion = () => { callback?.('default'); @@ -77,14 +78,28 @@ const Index: FC = ({ confirmBtnVariant: 'danger', confirmText: t('delete', { keyPrefix: 'btns' }), onConfirm: () => { - deleteQuestion({ - id: qid, - }).then(() => { - toast.onShow({ - msg: t('post_deleted', { keyPrefix: 'messages' }), - variant: 'success', - }); - callback?.('delete_question'); + dCaptcha.check(() => { + const req = { + id: qid, + captcha_code: undefined, + captcha_id: undefined, + }; + dCaptcha.resolveCaptchaReq(req); + + deleteQuestion(req) + .then(async () => { + await dCaptcha.close(); + toast.onShow({ + msg: t('post_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); + callback?.('delete_question'); + }) + .catch((ex) => { + if (ex.isError) { + dCaptcha.handleCaptchaError(ex.list); + } + }); }); }, }); @@ -98,15 +113,29 @@ const Index: FC = ({ confirmBtnVariant: 'danger', confirmText: t('delete', { keyPrefix: 'btns' }), onConfirm: () => { - deleteAnswer({ - id: aid, - }).then(() => { - // refresh page - toast.onShow({ - msg: t('tip_answer_deleted'), - variant: 'success', - }); - callback?.('all'); + dCaptcha.check(() => { + const req = { + id: aid, + captcha_code: undefined, + captcha_id: undefined, + }; + dCaptcha.resolveCaptchaReq(req); + + deleteAnswer(req) + .then(async () => { + await dCaptcha.close(); + // refresh page + toast.onShow({ + msg: t('tip_answer_deleted'), + variant: 'success', + }); + callback?.('all'); + }) + .catch((ex) => { + if (ex.isError) { + dCaptcha.handleCaptchaError(ex.list); + } + }); }); }, }); diff --git a/ui/src/components/Unactivate/index.tsx b/ui/src/components/Unactivate/index.tsx index e45eea5d9..f5673d7d4 100644 --- a/ui/src/components/Unactivate/index.tsx +++ b/ui/src/components/Unactivate/index.tsx @@ -1,24 +1,21 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Button, Col } from 'react-bootstrap'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { PicAuthCodeModal } from '@/components/Modal'; -import type { ImgCodeRes, ImgCodeReq, FormDataType } from '@/common/interface'; +import type { ImgCodeReq, FormDataType } from '@/common/interface'; import { loggedUserInfoStore } from '@/stores'; -import { resendEmail, checkImgCode } from '@/services'; -import { CAPTCHA_CODE_STORAGE_KEY } from '@/common/constants'; -import Storage from '@/utils/storage'; +import { resendEmail } from '@/services'; import { handleFormError } from '@/utils'; +import { useCaptchaModal } from '@/hooks'; interface IProps { - visible: boolean; + visible?: boolean; } -const Index: React.FC = ({ visible = false }) => { +const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'inactive' }); const [isSuccess, setSuccess] = useState(false); - const [showModal, setModalState] = useState(false); const { e_mail } = loggedUserInfoStore((state) => state.user); const [formData, setFormData] = useState({ captcha_code: { @@ -27,75 +24,39 @@ const Index: React.FC = ({ visible = false }) => { errorMsg: '', }, }); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, - }); - const getImgCode = () => { - checkImgCode({ - action: 'e_mail', - }).then((res) => { - setImgCode(res); - }); - }; + const emailCaptcha = useCaptchaModal('email'); - const submit = (e?: any) => { - if (e) { - e.preventDefault(); - } - let obj: ImgCodeReq = {}; + const submit = () => { + let req: ImgCodeReq = {}; + const imgCode = emailCaptcha.getCaptcha(); if (imgCode.verify) { - const code = Storage.get(CAPTCHA_CODE_STORAGE_KEY) || ''; - obj = { - captcha_code: code, + req = { + captcha_code: imgCode.captcha_code, captcha_id: imgCode.captcha_id, }; } - resendEmail(obj) + resendEmail(req) .then(() => { + emailCaptcha.close(); setSuccess(true); - setModalState(false); }) .catch((err) => { if (err.isError) { + emailCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); setFormData({ ...data }); } - }) - .finally(() => { - getImgCode(); }); }; - const onSentEmail = () => { - if (imgCode.verify) { - setModalState(true); - if (!formData.captcha_code.value) { - setFormData({ - captcha_code: { - value: '', - isInvalid: false, - errorMsg: t('msg.empty'), - }, - }); - } - return; - } - submit(); - }; - - const handleChange = (params: FormDataType) => { - setFormData({ ...formData, ...params }); + const onSentEmail = (evt) => { + evt.preventDefault(); + emailCaptcha.check(() => { + submit(); + }); }; - useEffect(() => { - if (visible) { - getImgCode(); - } - }, [visible]); - return ( {isSuccess ? ( @@ -124,18 +85,6 @@ const Index: React.FC = ({ visible = false }) => { )} - - setModalState(false)} - /> ); }; diff --git a/ui/src/hooks/index.ts b/ui/src/hooks/index.ts index 0538d3399..0aa11a3bc 100644 --- a/ui/src/hooks/index.ts +++ b/ui/src/hooks/index.ts @@ -11,6 +11,7 @@ import usePageTags from './usePageTags'; import useLoginRedirect from './useLoginRedirect'; import usePromptWithUnload from './usePrompt'; import useActivationEmailModal from './useActivationEmailModal'; +import useCaptchaModal from './useCaptchaModal'; export { useTagModal, @@ -26,4 +27,5 @@ export { useLoginRedirect, usePromptWithUnload, useActivationEmailModal, + useCaptchaModal, }; diff --git a/ui/src/hooks/useCaptchaModal/index.tsx b/ui/src/hooks/useCaptchaModal/index.tsx new file mode 100644 index 000000000..170f076ec --- /dev/null +++ b/ui/src/hooks/useCaptchaModal/index.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState, useLayoutEffect } from 'react'; +import { Modal, Form, Button, InputGroup } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import ReactDOM from 'react-dom/client'; + +import { Icon } from '@/components'; +import type { + FormValue, + ImgCodeRes, + CaptchaKey, + FieldError, + ImgCodeReq, +} from '@/common/interface'; +import { checkImgCode } from '@/services'; + +type SubmitCallback = { + (): void; +}; + +const Index = (captchaKey: CaptchaKey) => { + const refRoot = useRef(null); + if (refRoot.current === null) { + // @ts-ignore + refRoot.current = ReactDOM.createRoot(document.createElement('div')); + } + + const { t } = useTranslation('translation', { keyPrefix: 'pic_auth_code' }); + const refKey = useRef(captchaKey); + const refCallback = useRef(); + const pending = useRef(false); + const autoInitCaptchaData = /email/i.test(refKey.current); + + const [stateShow, setStateShow] = useState(false); + const [captcha, setCaptcha] = useState({ + captcha_id: '', + captcha_img: '', + verify: false, + }); + const [imgCode, setImgCode] = useState({ + value: '', + isInvalid: false, + errorMsg: '', + }); + const refCaptcha = useRef(captcha); + const refImgCode = useRef(imgCode); + + const fetchCaptchaData = () => { + pending.current = true; + checkImgCode(refKey.current) + .then((resp) => { + setCaptcha(resp); + }) + .finally(() => { + pending.current = false; + }); + }; + + const resetCapture = () => { + setCaptcha({ + captcha_id: '', + captcha_img: '', + verify: false, + }); + }; + + const show = () => { + if (!stateShow) { + setStateShow(true); + } + }; + /** + * There are some cases where the React scheduler cancels the execution of some functions, + * which prevents them from closing properly: + * for example, if the parent component uninstalls the child component directly, + * and the `captchaModal.close()` call is inside the child component. + * In this case, call `await captchaModal.close()` and wait for the close action to complete. + */ + const close = (reset = true) => { + setStateShow(false); + if (reset) { + resetCapture(); + } + const p = new Promise((resolve) => { + setTimeout(resolve); + }); + return p; + }; + + const handleCaptchaError = (fel: FieldError[] = []) => { + const captchaErr = fel.find((o) => { + return o.error_field === 'captcha_code'; + }); + + const ri = refImgCode.current; + if (captchaErr) { + /** + * `imgCode.value` No value but a validation error is received, + * indicating that it is the first time the interface has returned a CAPTCHA error, + * triggering the CAPTCHA logic. There is no need to display the error message at this point. + */ + if (ri.value) { + setImgCode({ + ...ri, + isInvalid: true, + errorMsg: captchaErr.error_msg, + }); + } + fetchCaptchaData(); + } else { + setImgCode({ + ...ri, + isInvalid: false, + errorMsg: '', + }); + close(); + } + }; + + const handleChange = (evt) => { + evt.preventDefault(); + setImgCode({ + value: evt.target.value || '', + isInvalid: false, + errorMsg: '', + }); + }; + + const getCaptcha = () => { + const rc = refCaptcha.current; + const ri = refImgCode.current; + const r = { + verify: !!rc?.verify, + captcha_id: rc?.captcha_id, + captcha_code: ri.value, + }; + + return r; + }; + + const resolveCaptchaReq = (req: ImgCodeReq) => { + const r = getCaptcha(); + if (r.verify) { + req.captcha_code = r.captcha_code; + req.captcha_id = r.captcha_id; + } + }; + + const handleSubmit = (evt) => { + evt.preventDefault(); + if (!imgCode.value) { + return; + } + + if (refCallback.current) { + refCallback.current(); + } + }; + + useEffect(() => { + if (autoInitCaptchaData) { + fetchCaptchaData(); + } + }, []); + + useLayoutEffect(() => { + refImgCode.current = imgCode; + refCaptcha.current = captcha; + }, [captcha, imgCode]); + + useEffect(() => { + // @ts-ignore + refRoot.current.render( + close(false)} + centered> + + {t('title')} + + +
+ +
+ captcha img +
+ + + + + + {imgCode?.errorMsg} + + +
+ +
+ +
+
+
+
, + ); + }); + + const r = { + close, + show, + check: (submitFunc: SubmitCallback) => { + if (pending.current) { + return false; + } + refCallback.current = submitFunc; + if (captcha?.verify) { + show(); + } + return submitFunc(); + }, + getCaptcha, + resolveCaptchaReq, + fetchCaptchaData, + handleCaptchaError, + }; + + return r; +}; + +export default Index; diff --git a/ui/src/hooks/useReportModal/index.tsx b/ui/src/hooks/useReportModal/index.tsx index 748564d9b..d0d29e911 100644 --- a/ui/src/hooks/useReportModal/index.tsx +++ b/ui/src/hooks/useReportModal/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import ReactDOM from 'react-dom/client'; -import { useToast } from '@/hooks'; +import { useToast, useCaptchaModal } from '@/hooks'; import type * as Type from '@/common/interface'; import { reportList, postReport, closeQuestion, putReport } from '@/services'; @@ -37,6 +37,8 @@ const useReportModal = (callback?: () => void) => { const [show, setShow] = useState(false); const [list, setList] = useState([]); + const rCaptcha = useCaptchaModal('report'); + useEffect(() => { const div = document.createElement('div'); rootRef.current.root = ReactDOM.createRoot(div); @@ -103,18 +105,32 @@ const useReportModal = (callback?: () => void) => { return; } if (!params.isBackend && params.action === 'flag') { - postReport({ - source: params.type, - report_type: reportType.type, - object_id: params.id, - content: content.value, - }).then(() => { - toast.onShow({ - msg: t('flag_success', { keyPrefix: 'toast' }), - variant: 'warning', - }); - onClose(); - asyncCallback(); + rCaptcha.check(() => { + const flagReq = { + source: params.type, + report_type: reportType.type, + object_id: params.id, + content: content.value, + captcha_code: undefined, + captcha_id: undefined, + }; + rCaptcha.resolveCaptchaReq(flagReq); + + postReport(flagReq) + .then(async () => { + await rCaptcha.close(); + toast.onShow({ + msg: t('flag_success', { keyPrefix: 'toast' }), + variant: 'warning', + }); + onClose(); + asyncCallback(); + }) + .catch((ex) => { + if (ex.isError) { + rCaptcha.handleCaptchaError(ex.list); + } + }); }); } diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index 0f3b7f478..03b2cda69 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -7,7 +7,7 @@ import dayjs from 'dayjs'; import classNames from 'classnames'; import { isEqual } from 'lodash'; -import { usePageTags, usePromptWithUnload } from '@/hooks'; +import { usePageTags, usePromptWithUnload, useCaptchaModal } from '@/hooks'; import { Editor, EditorRef, TagSelector } from '@/components'; import type * as Type from '@/common/interface'; import { DRAFT_QUESTION_STORAGE_KEY } from '@/common/constants'; @@ -102,6 +102,9 @@ const Ask = () => { isEdit ? '' : formData.title.value, ); + const saveCaptcha = useCaptchaModal('question'); + const editCaptcha = useCaptchaModal('edit'); + const removeDraft = () => { saveDraft.save.cancel(); saveDraft.remove(); @@ -251,52 +254,69 @@ const Ask = () => { tags: formData.tags.value, }; if (isEdit) { - modifyQuestion({ - ...params, - id: qid, - edit_summary: formData.edit_summary.value, - }) - .then((res) => { - navigate(pathFactory.questionLanding(qid, params.url_title), { - state: { isReview: res?.wait_for_review }, + editCaptcha.check(() => { + const ep = { + ...params, + id: qid, + edit_summary: formData.edit_summary.value, + }; + const imgCode = editCaptcha.getCaptcha(); + if (imgCode.verify) { + ep.captcha_code = imgCode.captcha_code; + ep.captcha_id = imgCode.captcha_id; + } + modifyQuestion(ep) + .then(async (res) => { + await editCaptcha.close(); + navigate(pathFactory.questionLanding(qid, params.url_title), { + state: { isReview: res?.wait_for_review }, + }); + }) + .catch((err) => { + if (err.isError) { + editCaptcha.handleCaptchaError(err.list); + const data = handleFormError(err, formData); + setFormData({ ...data }); + } }); - }) - .catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - } - }); + }); } else { - let res; - if (checked) { - res = await saveQuestionWidthAnaser({ - ...params, - answer_content: formData.answer_content.value, - }).catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - } - }); - } else { - res = await saveQuestion(params).catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - } - }); - } - - const id = res?.id || res?.question?.id; - if (id) { + saveCaptcha.check(async () => { + const imgCode = saveCaptcha.getCaptcha(); + if (imgCode.verify) { + params.captcha_code = imgCode.captcha_code; + params.captcha_id = imgCode.captcha_id; + } + let res; if (checked) { - navigate(pathFactory.questionLanding(id, res?.question?.url_title)); + res = await saveQuestionWidthAnaser({ + ...params, + answer_content: formData.answer_content.value, + }).catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + } + }); } else { - navigate(pathFactory.questionLanding(id)); + res = await saveQuestion(params).catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + } + }); } - } - removeDraft(); + + const id = res?.id || res?.question?.id; + if (id) { + if (checked) { + navigate(pathFactory.questionLanding(id, res?.question?.url_title)); + } else { + navigate(pathFactory.questionLanding(id)); + } + } + removeDraft(); + }); } }; const backPage = () => { diff --git a/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx index 7c8bf16e0..b3c41631a 100644 --- a/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { Avatar } from '@/components'; import { getInviteUser, putInviteUser } from '@/services'; import type * as Type from '@/common/interface'; +import { useCaptchaModal } from '@/hooks'; import PeopleDropdown from './PeopleDropdown'; @@ -22,6 +23,7 @@ const Index: FC = ({ questionId, readOnly = false }) => { const MAX_ASK_NUMBER = 5; const [editing, setEditing] = useState(false); const [users, setUsers] = useState(); + const iaCaptcha = useCaptchaModal('invitation_answer'); const initInviteUsers = () => { if (!questionId) { @@ -60,14 +62,23 @@ const Index: FC = ({ questionId, readOnly = false }) => { const names = users.map((_) => { return _.username; }); - putInviteUser(questionId, names) - .then(() => { - setEditing(false); - }) - .catch((ex) => { - console.log('ex: ', ex); - }); + iaCaptcha.check(() => { + const imgCode: Type.ImgCodeReq = {}; + iaCaptcha.resolveCaptchaReq(imgCode); + putInviteUser(questionId, names, imgCode) + .then(async () => { + await iaCaptcha.close(); + setEditing(false); + }) + .catch((ex) => { + if (ex.isError) { + iaCaptcha.handleCaptchaError(ex.list); + } + console.log('ex: ', ex); + }); + }); }; + useEffect(() => { initInviteUsers(); }, [questionId]); diff --git a/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx b/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx index b80e767c1..b1d84ec67 100644 --- a/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx @@ -5,9 +5,9 @@ import { useTranslation, Trans } from 'react-i18next'; import { marked } from 'marked'; import classNames from 'classnames'; -import { usePromptWithUnload } from '@/hooks'; +import { usePromptWithUnload, useCaptchaModal } from '@/hooks'; import { Editor, Modal, TextArea } from '@/components'; -import { FormDataType } from '@/common/interface'; +import { FormDataType, PostAnswerReq } from '@/common/interface'; import { postAnswer } from '@/services'; import { guard, handleFormError, SaveDraft, storageExpires } from '@/utils'; import { DRAFT_ANSWER_STORAGE_KEY } from '@/common/constants'; @@ -41,6 +41,7 @@ const Index: FC = ({ visible = false, data, callback }) => { const [editorFocusState, setEditorFocusState] = useState(false); const [hasDraft, setHasDraft] = useState(false); const [showTips, setShowTips] = useState(data.loggedUserRank < 100); + const aCaptcha = useCaptchaModal('answer'); usePromptWithUnload({ when: Boolean(formData.content.value), @@ -135,29 +136,40 @@ const Index: FC = ({ visible = false, data, callback }) => { if (!checkValidated()) { return; } - postAnswer({ - question_id: data?.qid, - content: formData.content.value, - html: marked.parse(formData.content.value), - }) - .then((res) => { - setShowEditor(false); - setFormData({ - content: { - value: '', - isInvalid: false, - errorMsg: '', - }, + + aCaptcha.check(() => { + const params: PostAnswerReq = { + question_id: data?.qid, + content: formData.content.value, + html: marked.parse(formData.content.value), + }; + const imgCode = aCaptcha.getCaptcha(); + if (imgCode.verify) { + params.captcha_code = imgCode.captcha_code; + params.captcha_id = imgCode.captcha_id; + } + postAnswer(params) + .then(async (res) => { + await aCaptcha.close(); + setShowEditor(false); + setFormData({ + content: { + value: '', + isInvalid: false, + errorMsg: '', + }, + }); + removeDraft(); + callback?.(res.info); + }) + .catch((ex) => { + if (ex.isError) { + aCaptcha.handleCaptchaError(ex.list); + const stateData = handleFormError(ex, formData); + setFormData({ ...stateData }); + } }); - removeDraft(); - callback?.(res.info); - }) - .catch((ex) => { - if (ex.isError) { - const stateData = handleFormError(ex, formData); - setFormData({ ...stateData }); - } - }); + }); }; const clickBtn = () => { diff --git a/ui/src/pages/Questions/EditAnswer/index.tsx b/ui/src/pages/Questions/EditAnswer/index.tsx index c3b1342f0..7f3e3ab26 100644 --- a/ui/src/pages/Questions/EditAnswer/index.tsx +++ b/ui/src/pages/Questions/EditAnswer/index.tsx @@ -7,7 +7,7 @@ import dayjs from 'dayjs'; import classNames from 'classnames'; import { handleFormError, scrollToDocTop } from '@/utils'; -import { usePageTags, usePromptWithUnload } from '@/hooks'; +import { usePageTags, usePromptWithUnload, useCaptchaModal } from '@/hooks'; import { pathFactory } from '@/router/pathFactory'; import { Editor, EditorRef, Icon, htmlRender } from '@/components'; import type * as Type from '@/common/interface'; @@ -51,6 +51,7 @@ const Index = () => { const [formData, setFormData] = useState(initFormData); const [immData, setImmData] = useState(initFormData); const [contentChanged, setContentChanged] = useState(false); + const editCaptcha = useCaptchaModal('edit'); useLayoutEffect(() => { if (data?.info?.content) { @@ -136,36 +137,43 @@ const Index = () => { event.preventDefault(); event.stopPropagation(); + if (!checkValidated()) { return; } - const params: Type.AnswerParams = { - content: formData.content.value, - html: editorRef.current.getHtml(), - question_id: qid, - id: aid, - edit_summary: formData.description.value, - }; - modifyAnswer(params) - .then((res) => { - navigate( - pathFactory.answerLanding({ - questionId: qid, - slugTitle: data?.question?.url_title, - answerId: aid, - }), - { - state: { isReview: res?.wait_for_review }, - }, - ); - }) - .catch((ex) => { - if (ex.isError) { - const stateData = handleFormError(ex, formData); - setFormData({ ...stateData }); - } - }); + editCaptcha.check(() => { + const params: Type.AnswerParams = { + content: formData.content.value, + html: editorRef.current.getHtml(), + question_id: qid, + id: aid, + edit_summary: formData.description.value, + }; + editCaptcha.resolveCaptchaReq(params); + + modifyAnswer(params) + .then(async (res) => { + await editCaptcha.close(); + navigate( + pathFactory.answerLanding({ + questionId: qid, + slugTitle: data?.question?.url_title, + answerId: aid, + }), + { + state: { isReview: res?.wait_for_review }, + }, + ); + }) + .catch((ex) => { + if (ex.isError) { + editCaptcha.handleCaptchaError(ex.list); + const stateData = handleFormError(ex, formData); + setFormData({ ...stateData }); + } + }); + }); }; const handleSelectedRevision = (e) => { const index = e.target.value; diff --git a/ui/src/pages/Search/index.tsx b/ui/src/pages/Search/index.tsx index 4ea10ee3e..7443d05a9 100644 --- a/ui/src/pages/Search/index.tsx +++ b/ui/src/pages/Search/index.tsx @@ -1,10 +1,12 @@ import { Row, Col, ListGroup } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; -import { usePageTags } from '@/hooks'; +import { usePageTags, useCaptchaModal } from '@/hooks'; import { Pagination } from '@/components'; -import { useSearch } from '@/services'; +import { getSearchResult } from '@/services'; +import type { SearchParams, SearchRes } from '@/common/interface'; import { Head, @@ -21,15 +23,52 @@ const Index = () => { const page = searchParams.get('page') || 1; const q = searchParams.get('q') || ''; const order = searchParams.get('order') || 'active'; - - const { data, isLoading } = useSearch({ - q, - order, - page: Number(page), - size: 20, + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState({ + count: 0, + list: [], + extra: null, }); - const { count = 0, list = [], extra = null } = data || {}; + + const searchCaptcha = useCaptchaModal('search'); + + const doSearch = () => { + setIsLoading(true); + const params: SearchParams = { + q, + order, + page: Number(page), + size: 20, + }; + + const captcha = searchCaptcha.getCaptcha(); + if (captcha?.verify) { + params.captcha_id = captcha.captcha_id; + params.captcha_code = captcha.captcha_code; + } + + getSearchResult(params) + .then((resp) => { + searchCaptcha.close(); + setData(resp); + }) + .catch((err) => { + if (err.isError) { + searchCaptcha.handleCaptchaError(err.list); + } + }) + .finally(() => { + setIsLoading(false); + }); + }; + + useEffect(() => { + searchCaptcha.check(() => { + doSearch(); + }); + }, [q, order, page]); + let pageTitle = t('search', { keyPrefix: 'page_title' }); if (q) { pageTitle = `${t('posts_containing', { keyPrefix: 'page_title' })} '${q}'`; @@ -37,6 +76,7 @@ const Index = () => { usePageTags({ title: pageTitle, }); + return ( diff --git a/ui/src/pages/Users/AccountForgot/components/sendEmail.tsx b/ui/src/pages/Users/AccountForgot/components/sendEmail.tsx index 8c6aa1613..323d91135 100644 --- a/ui/src/pages/Users/AccountForgot/components/sendEmail.tsx +++ b/ui/src/pages/Users/AccountForgot/components/sendEmail.tsx @@ -1,22 +1,19 @@ -import { FC, memo, useEffect, useState } from 'react'; +import { FC, memo, useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import type { - ImgCodeRes, - PasswordResetReq, - FormDataType, -} from '@/common/interface'; -import { resetPassword, checkImgCode } from '@/services'; -import { PicAuthCodeModal } from '@/components/Modal'; +import type { PasswordResetReq, FormDataType } from '@/common/interface'; +import { resetPassword } from '@/services'; import { handleFormError } from '@/utils'; +import { useCaptchaModal } from '@/hooks'; interface IProps { - visible: boolean; + // eslint-disable-next-line react/no-unused-prop-types + visible?: boolean; callback: (param: number, email: string) => void; } -const Index: FC = ({ visible = false, callback }) => { +const Index: FC = ({ callback }) => { const { t } = useTranslation('translation', { keyPrefix: 'account_forgot' }); const [formData, setFormData] = useState({ e_mail: { @@ -24,26 +21,9 @@ const Index: FC = ({ visible = false, callback }) => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, }); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, - }); - const [showModal, setModalState] = useState(false); - const getImgCode = () => { - checkImgCode({ - action: 'find_pass', - }).then((res) => { - setImgCode(res); - }); - }; + const emailCaptcha = useCaptchaModal('email'); const handleChange = (params: FormDataType) => { setFormData({ ...formData, ...params }); @@ -73,27 +53,24 @@ const Index: FC = ({ visible = false, callback }) => { const params: PasswordResetReq = { e_mail: formData.e_mail.value, }; - if (imgCode.verify) { - params.captcha_code = formData.captcha_code.value; - params.captcha_id = imgCode.captcha_id; + + const captcha = emailCaptcha.getCaptcha(); + if (captcha.verify) { + params.captcha_code = captcha.captcha_code; + params.captcha_id = captcha.captcha_id; } resetPassword(params) - .then(() => { + .then(async () => { + await emailCaptcha.close(); callback?.(2, formData.e_mail.value); - setModalState(false); }) .catch((err) => { if (err.isError) { + emailCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } setFormData({ ...data }); } - }) - .finally(() => { - getImgCode(); }); }; @@ -105,64 +82,41 @@ const Index: FC = ({ visible = false, callback }) => { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - - sendEmail(); + emailCaptcha.check(() => { + sendEmail(); + }); }; - useEffect(() => { - if (visible) { - getImgCode(); - } - }, [visible]); - return ( - <> -
- - {t('email.label')} - { - handleChange({ - e_mail: { - value: e.target.value, - isInvalid: false, - errorMsg: '', - }, - }); - }} - /> - - {formData.e_mail.errorMsg} - - - -
- -
-
- - setModalState(false)} - /> - +
+ + {t('email.label')} + { + handleChange({ + e_mail: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {formData.e_mail.errorMsg} + + + +
+ +
+
); }; diff --git a/ui/src/pages/Users/ChangeEmail/components/sendEmail.tsx b/ui/src/pages/Users/ChangeEmail/components/sendEmail.tsx index 6f884ca09..7143c0993 100644 --- a/ui/src/pages/Users/ChangeEmail/components/sendEmail.tsx +++ b/ui/src/pages/Users/ChangeEmail/components/sendEmail.tsx @@ -1,17 +1,13 @@ -import { FC, memo, useEffect, useState } from 'react'; +import { FC, memo, useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import type { - ImgCodeRes, - PasswordResetReq, - FormDataType, -} from '@/common/interface'; +import type { PasswordResetReq, FormDataType } from '@/common/interface'; import { loggedUserInfoStore } from '@/stores'; -import { changeEmail, checkImgCode } from '@/services'; -import { PicAuthCodeModal } from '@/components/Modal'; +import { changeEmail } from '@/services'; import { handleFormError } from '@/utils'; +import { useCaptchaModal } from '@/hooks'; const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'change_email' }); @@ -21,28 +17,12 @@ const Index: FC = () => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, - }); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, }); - const [showModal, setModalState] = useState(false); + const navigate = useNavigate(); const { user: userInfo, update: updateUser } = loggedUserInfoStore(); - const getImgCode = () => { - checkImgCode({ - action: 'e_mail', - }).then((res) => { - setImgCode(res); - }); - }; + const emailCaptcha = useCaptchaModal('email'); const handleChange = (params: FormDataType) => { setFormData({ ...formData, ...params }); @@ -72,28 +52,25 @@ const Index: FC = () => { const params: PasswordResetReq = { e_mail: formData.e_mail.value, }; + const imgCode = emailCaptcha.getCaptcha(); if (imgCode.verify) { - params.captcha_code = formData.captcha_code.value; + params.captcha_code = imgCode.captcha_code; params.captcha_id = imgCode.captcha_id; } + changeEmail(params) - .then(() => { + .then(async () => { + await emailCaptcha.close(); userInfo.e_mail = formData.e_mail.value; updateUser(userInfo); navigate('/users/login', { replace: true }); - setModalState(false); }) .catch((err) => { if (err.isError) { + emailCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } setFormData({ ...data }); } - }) - .finally(() => { - getImgCode(); }); }; @@ -104,69 +81,48 @@ const Index: FC = () => { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - - sendEmail(); + emailCaptcha.check(() => { + sendEmail(); + }); }; const goBack = () => { navigate('/users/login?status=inactive', { replace: true }); }; - useEffect(() => { - getImgCode(); - }, []); - return ( - <> -
- - {t('email.label')} - { - handleChange({ - e_mail: { - value: e.target.value, - isInvalid: false, - errorMsg: '', - }, - }); - }} - /> - - {formData.e_mail.errorMsg} - - - -
- - -
-
- - setModalState(false)} - /> - +
+ + {t('email.label')} + { + handleChange({ + e_mail: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {formData.e_mail.errorMsg} + + + +
+ + +
+
); }; diff --git a/ui/src/pages/Users/Login/index.tsx b/ui/src/pages/Users/Login/index.tsx index a6bedd59f..56bcec789 100644 --- a/ui/src/pages/Users/Login/index.tsx +++ b/ui/src/pages/Users/Login/index.tsx @@ -3,12 +3,8 @@ import { Container, Form, Button, Col } from 'react-bootstrap'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { usePageTags } from '@/hooks'; -import type { - LoginReqParams, - ImgCodeRes, - FormDataType, -} from '@/common/interface'; +import { usePageTags, useCaptchaModal } from '@/hooks'; +import type { LoginReqParams, FormDataType } from '@/common/interface'; import { Unactivate, WelcomeTitle, PluginRender } from '@/components'; import { loggedUserInfoStore, @@ -16,14 +12,12 @@ import { userCenterStore, } from '@/stores'; import { floppyNavigation, guard, handleFormError, userCenter } from '@/utils'; -import { login, checkImgCode, UcAgent } from '@/services'; -import { PicAuthCodeModal } from '@/components/Modal'; +import { login, UcAgent } from '@/services'; const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'login' }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [refresh, setRefresh] = useState(0); const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _); const loginSetting = loginSettingStore((state) => state.login); const ucAgent = userCenterStore().agent; @@ -45,34 +39,15 @@ const Index: React.FC = () => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, - }); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, }); - const [showModal, setModalState] = useState(false); + const [step, setStep] = useState(1); const handleChange = (params: FormDataType) => { setFormData({ ...formData, ...params }); }; - const getImgCode = () => { - if (!canOriginalLogin) { - return; - } - checkImgCode({ - action: 'login', - }).then((res) => { - setImgCode(res); - }); - }; + const passwordCaptcha = useCaptchaModal('password'); const checkValidated = (): boolean => { let bol = true; @@ -110,34 +85,31 @@ const Index: React.FC = () => { e_mail: formData.e_mail.value, pass: formData.pass.value, }; - if (imgCode.verify) { - params.captcha_code = formData.captcha_code.value; - params.captcha_id = imgCode.captcha_id; + + const captcha = passwordCaptcha.getCaptcha(); + if (captcha?.verify) { + params.captcha_code = captcha.captcha_code; + params.captcha_id = captcha.captcha_id; } login(params) .then((res) => { + passwordCaptcha.close(); updateUser(res); const userStat = guard.deriveLoginState(); if (userStat.isNotActivated) { // inactive setStep(2); - setRefresh((pre) => pre + 1); } else { guard.handleLoginRedirect(navigate); } - - setModalState(false); }) .catch((err) => { if (err.isError) { const data = handleFormError(err, formData); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } setFormData({ ...data }); + passwordCaptcha.handleCaptchaError(err.list); } - setRefresh((pre) => pre + 1); }); }; @@ -149,18 +121,11 @@ const Index: React.FC = () => { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - - handleLogin(); + passwordCaptcha.check(() => { + handleLogin(); + }); }; - useEffect(() => { - getImgCode(); - }, [refresh]); - useEffect(() => { const isInactive = searchParams.get('status'); @@ -168,6 +133,7 @@ const Index: React.FC = () => { setStep(2); } }, []); + usePageTags({ title: t('login', { keyPrefix: 'page_title' }), }); @@ -263,18 +229,6 @@ const Index: React.FC = () => { ) : null} {step === 2 && } - - setModalState(false)} - /> ); }; diff --git a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx index 594740881..477689345 100644 --- a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx +++ b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx @@ -1,17 +1,11 @@ -import React, { FormEvent, MouseEvent, useEffect, useState } from 'react'; +import React, { FormEvent, MouseEvent, useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { PicAuthCodeModal } from '@/components/Modal'; -import { ImgCodeRes } from '@/common/interface'; +import { useCaptchaModal } from '@/hooks'; import type { FormDataType, RegisterReqParams } from '@/common/interface'; -import { - register, - getRegisterCaptcha, - useLegalTos, - useLegalPrivacy, -} from '@/services'; +import { register, useLegalTos, useLegalPrivacy } from '@/services'; import userStore from '@/stores/loggedUserInfo'; import { handleFormError } from '@/utils'; @@ -37,25 +31,11 @@ const Index: React.FC = ({ callback }) => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, }); + const updateUser = userStore((state) => state.update); + const emailCaptcha = useCaptchaModal('email'); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, - }); - const [showModal, setModalState] = useState(false); - const getImgCode = () => { - getRegisterCaptcha().then((res) => { - setImgCode(res); - }); - }; const handleChange = (params: FormDataType) => { setFormData({ ...formData, ...params }); }; @@ -86,6 +66,7 @@ const Index: React.FC = ({ callback }) => { }); return bol; }; + const { data: tos } = useLegalTos(); const { data: privacy } = useLegalPrivacy(); const argumentClick = (evt: MouseEvent, type: 'tos' | 'privacy') => { @@ -117,25 +98,24 @@ const Index: React.FC = ({ callback }) => { pass: formData.pass.value, }; - if (imgCode.verify) { - reqParams.captcha_code = formData.captcha_code.value; - reqParams.captcha_id = imgCode.captcha_id; + const captcha = emailCaptcha.getCaptcha(); + if (captcha?.verify) { + reqParams.captcha_code = captcha.captcha_code; + reqParams.captcha_id = captcha.captcha_id; } + register(reqParams) .then((res) => { + emailCaptcha.close(); updateUser(res); - setModalState(false); callback(); }) .catch((err) => { if (err.isError) { + emailCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } setFormData({ ...data }); } - getImgCode(); }); }; @@ -145,15 +125,11 @@ const Index: React.FC = ({ callback }) => { if (!checkValidated()) { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - handleRegister(); + emailCaptcha.check(() => { + handleRegister(); + }); }; - useEffect(() => { - getImgCode(); - }, []); + return ( <>
@@ -260,18 +236,6 @@ const Index: React.FC = ({ callback }) => { Already have an account? Log in - - setModalState(false)} - /> ); }; diff --git a/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx b/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx index a934383d4..3c5fdee14 100644 --- a/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx @@ -3,22 +3,15 @@ import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import type * as Type from '@/common/interface'; -import { useToast } from '@/hooks'; -import { getLoggedUserInfo, changeEmail, checkImgCode } from '@/services'; +import { useToast, useCaptchaModal } from '@/hooks'; +import { getLoggedUserInfo, changeEmail } from '@/services'; import { handleFormError } from '@/utils'; -import { PicAuthCodeModal } from '@/components'; const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'settings.account', }); const [step, setStep] = useState(1); - const [showModal, setModalState] = useState(false); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, - }); const [formData, setFormData] = useState({ e_mail: { value: '', @@ -30,28 +23,17 @@ const Index: FC = () => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, }); const [userInfo, setUserInfo] = useState(); const toast = useToast(); + const emailCaptcha = useCaptchaModal('edit_userinfo'); + useEffect(() => { getLoggedUserInfo().then((resp) => { setUserInfo(resp); }); }, []); - const getImgCode = () => { - checkImgCode({ - action: 'e_mail', - }).then((res) => { - setImgCode(res); - }); - }; - const handleChange = (params: Type.FormDataType) => { setFormData({ ...formData, ...params }); }; @@ -95,11 +77,6 @@ const Index: FC = () => { isInvalid: false, errorMsg: '', }, - captcha_code: { - value: '', - isInvalid: false, - errorMsg: '', - }, }); }; @@ -112,14 +89,15 @@ const Index: FC = () => { pass: formData.pass.value, }; + const imgCode = emailCaptcha.getCaptcha(); if (imgCode.verify) { - params.captcha_code = formData.captcha_code.value; + params.captcha_code = imgCode.captcha_code; params.captcha_id = imgCode.captcha_id; } changeEmail(params) - .then(() => { + .then(async () => { + await emailCaptcha.close(); setStep(1); - setModalState(false); toast.onShow({ msg: t('change_email_info'), variant: 'warning', @@ -128,15 +106,10 @@ const Index: FC = () => { }) .catch((err) => { if (err.isError) { + emailCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); setFormData({ ...data }); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } } - }) - .finally(() => { - getImgCode(); }); }; @@ -147,11 +120,9 @@ const Index: FC = () => { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - postEmail(); + emailCaptcha.check(() => { + postEmail(); + }); }; return ( @@ -174,7 +145,6 @@ const Index: FC = () => { variant="outline-secondary" onClick={() => { setStep(2); - getImgCode(); }}> {t('change_email_btn')} @@ -240,18 +210,6 @@ const Index: FC = () => { )} - - setModalState(false)} - /> ); }; diff --git a/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx b/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx index a06cb9876..3caf7c760 100644 --- a/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx @@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next'; import classname from 'classnames'; -import { useToast } from '@/hooks'; -import type { FormDataType, ImgCodeRes } from '@/common/interface'; -import { modifyPassword, checkImgCode } from '@/services'; +import { useToast, useCaptchaModal } from '@/hooks'; +import type { FormDataType } from '@/common/interface'; +import { modifyPassword } from '@/services'; import { handleFormError } from '@/utils'; import { loggedUserInfoStore } from '@/stores'; -import { PicAuthCodeModal } from '@/components'; const Index: FC = () => { const { t } = useTranslation('translation', { @@ -35,20 +34,8 @@ const Index: FC = () => { errorMsg: '', }, }); - const [showModal, setModalState] = useState(false); - const [imgCode, setImgCode] = useState({ - captcha_id: '', - captcha_img: '', - verify: false, - }); - const getImgCode = () => { - checkImgCode({ - action: 'modify_pass', - }).then((res) => { - setImgCode(res); - }); - }; + const infoCaptcha = useCaptchaModal('edit_userinfo'); const handleFormState = () => { setFormState((pre) => !pre); @@ -128,13 +115,14 @@ const Index: FC = () => { pass: formData.pass.value, }; + const imgCode = infoCaptcha.getCaptcha(); if (imgCode.verify) { - params.captcha_code = formData.captcha_code.value; + params.captcha_code = imgCode.captcha_code; params.captcha_id = imgCode.captcha_id; } modifyPassword(params) - .then(() => { - setModalState(false); + .then(async () => { + await infoCaptcha.close(); toast.onShow({ msg: t('update_password', { keyPrefix: 'toast' }), variant: 'success', @@ -143,15 +131,10 @@ const Index: FC = () => { }) .catch((err) => { if (err.isError) { + infoCaptcha.handleCaptchaError(err.list); const data = handleFormError(err, formData); - if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) { - setModalState(false); - } setFormData({ ...data }); } - }) - .finally(() => { - getImgCode(); }); }; @@ -162,11 +145,9 @@ const Index: FC = () => { return; } - if (imgCode.verify) { - setModalState(true); - return; - } - postModifyPass(); + infoCaptcha.check(() => { + postModifyPass(); + }); }; return ( @@ -262,24 +243,11 @@ const Index: FC = () => { type="submit" onClick={() => { handleFormState(); - getImgCode(); }}> {t('change_pass_btn')} )} - - setModalState(false)} - /> ); }; diff --git a/ui/src/services/client/question.ts b/ui/src/services/client/question.ts index db66462ad..92cc55048 100644 --- a/ui/src/services/client/question.ts +++ b/ui/src/services/client/question.ts @@ -61,10 +61,15 @@ export const getInviteUser = (questionId: string) => { }); }; -export const putInviteUser = (questionId: string, users: string[]) => { +export const putInviteUser = ( + questionId: string, + users: string[], + imgCode: Type.ImgCodeReq = {}, +) => { const apiUrl = '/answer/api/v1/question/invite'; return request.put(apiUrl, { id: questionId, invite_user: users, + ...imgCode, }); }; diff --git a/ui/src/services/client/search.ts b/ui/src/services/client/search.ts index 8d3802940..ce6e9ad4e 100644 --- a/ui/src/services/client/search.ts +++ b/ui/src/services/client/search.ts @@ -1,20 +1,10 @@ -import useSWR from 'swr'; -import qs from 'qs'; - import request from '@/utils/request'; import type * as Type from '@/common/interface'; -export const useSearch = (params?: Type.SearchParams) => { +export const getSearchResult = (params?: Type.SearchParams) => { const apiUrl = '/answer/api/v1/search'; - const queryParams = qs.stringify(params, { skipNulls: true }); - const { data, error, mutate } = useSWR( - params?.q ? `${apiUrl}?${queryParams}` : null, - request.instance.get, - ); - return { - data, - isLoading: !data && !error, - error, - mutate, - }; + + return request.get(apiUrl, { + params, + }); }; diff --git a/ui/src/services/common.ts b/ui/src/services/common.ts index cf750a525..bf44dc499 100644 --- a/ui/src/services/common.ts +++ b/ui/src/services/common.ts @@ -60,9 +60,10 @@ export const updateComment = (params) => { return request.put('/answer/api/v1/comment', params); }; -export const deleteComment = (id) => { +export const deleteComment = (id, imgCode: Type.ImgCodeReq = {}) => { return request.delete('/answer/api/v1/comment', { comment_id: id, + ...imgCode, }); }; @@ -102,19 +103,10 @@ export const register = (params: Type.RegisterReqParams) => { return request.post('/answer/api/v1/user/register/email', params); }; -export const getRegisterCaptcha = () => { - const apiUrl = '/answer/api/v1/user/register/captcha'; - return request.get(apiUrl); -}; - export const logout = () => { return request.get('/answer/api/v1/user/logout'); }; -export const verifyEmail = (code: string) => { - return request.get(`/answer/api/v1/email/verify?code=${code}`); -}; - export const resendEmail = (params?: Type.ImgCodeReq) => { params = qs.parse( qs.stringify(params, { @@ -134,19 +126,19 @@ export const getLoggedUserInfo = (config = { passingError: false }) => { return request.get('/answer/api/v1/user/info', config); }; -export const modifyPassword = (params: Type.ModifyPasswordReq) => { - return request.put('/answer/api/v1/user/password', params); -}; - export const modifyUserInfo = (params: Type.ModifyUserReq) => { return request.put('/answer/api/v1/user/info', params); }; +export const modifyPassword = (params: Type.ModifyPasswordReq) => { + return request.put('/answer/api/v1/user/password', params); +}; + export const resetPassword = (params: Type.PasswordResetReq) => { return request.post('/answer/api/v1/user/password/reset', params); }; -export const replacementPassword = (params: { code: string; pass: string }) => { +export const replacementPassword = (params: Type.PasswordReplaceReq) => { return request.post('/answer/api/v1/user/password/replacement', params); }; @@ -154,10 +146,13 @@ export const activateAccount = (code: string) => { return request.post(`/answer/api/v1/user/email/verification`, { code }); }; -export const checkImgCode = (params: Type.CheckImgReq) => { - return request.get( - `/answer/api/v1/user/action/record?${qs.stringify(params)}`, - ); +export const checkImgCode = (k: Type.CaptchaKey) => { + const apiUrl = `/answer/api/v1/user/action/record`; + return request.get(apiUrl, { + params: { + action: k, + }, + }); }; export const setNotice = (params: Type.SetNoticeReq) => { @@ -189,7 +184,7 @@ export const bookmark = (params: { group_id: string; object_id: string }) => { }; export const postVote = ( - params: { object_id: string; is_cancel: boolean }, + params: { object_id: string; is_cancel: boolean } & Type.ImgCodeReq, type: 'down' | 'up', ) => { return request.post(`/answer/api/v1/vote/${type}`, params); @@ -224,20 +219,30 @@ export const reportList = ({ return request.get(`${api}?object_type=${type}&action=${action}`); }; -export const postReport = (params: { - source: Type.ReportType; - content: string; - object_id: string; - report_type: number; -}) => { +export const postReport = ( + params: { + source: Type.ReportType; + content: string; + object_id: string; + report_type: number; + } & Type.ImgCodeReq, +) => { return request.post('/answer/api/v1/report', params); }; -export const deleteQuestion = (params: { id: string }) => { +export const deleteQuestion = (params: { + id: string; + captcha_code?: string; + captcha_id?: string; +}) => { return request.delete('/answer/api/v1/question', params); }; -export const deleteAnswer = (params: { id: string }) => { +export const deleteAnswer = (params: { + id: string; + captcha_code?: string; + captcha_id?: string; +}) => { return request.delete('/answer/api/v1/answer', params); }; diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index e61e5e7df..efd93e0ac 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -2,8 +2,7 @@ import i18next from 'i18next'; import pattern from '@/common/pattern'; import { USER_AGENT_NAMES } from '@/common/constants'; - -const Diff = require('diff'); +import type * as Type from '@/common/interface'; function thousandthDivision(num) { const reg = /\d{1,3}(?=(\d{3})+$)/g; @@ -114,7 +113,7 @@ function escapeRemove(str: string) { } function handleFormError( - error: { list: Array<{ error_field: string; error_msg: string }> }, + error: { list: Type.FieldError[] }, data: any, keymap?: Array<{ from: string; to: string }>, ) { @@ -148,6 +147,8 @@ function escapeHtml(str: string) { return str.replace(/[&<>"'`]/g, (tag) => tagsToReplace[tag] || tag); } +const Diff = require('diff'); + function diffText(newText: string, oldText?: string): string { if (!newText) { return ''; diff --git a/ui/src/utils/floppyNavigation.ts b/ui/src/utils/floppyNavigation.ts index 7ed3a77b7..707d5c48a 100644 --- a/ui/src/utils/floppyNavigation.ts +++ b/ui/src/utils/floppyNavigation.ts @@ -94,36 +94,42 @@ export interface NavigateConfig { } const navigate = (to: string | number, config: NavigateConfig = {}) => { let { handler = 'href' } = config; - if (to && typeof to === 'string') { - if (equalToCurrentHref(to)) { - return; - } - /** - * 1. Blocking redirection of two login pages - * 2. Auto storage login redirect - * Note: The or judgement cannot be missing here, both jumps will be used - */ - if (to === RouteAlias.login || to === getLoginUrl()) { - storageLoginRedirect(); + /** + * Note: Synchronised navigation can result in asynchronous actions such as page animations and state modifications not being completed. + */ + setTimeout(() => { + if (to && typeof to === 'string') { + if (equalToCurrentHref(to)) { + return; + } + /** + * 1. Blocking redirection of two login pages + * 2. Auto storage login redirect + * Note: The or judgement cannot be missing here, both jumps will be used + */ + if (to === RouteAlias.login || to === getLoginUrl()) { + storageLoginRedirect(); + } + + if (!isRoutableLink(to) && handler !== 'href' && handler !== 'replace') { + handler = 'href'; + } + if (handler === 'href' && config.options?.replace) { + handler = 'replace'; + } + if (handler === 'href') { + window.location.href = to; + } else if (handler === 'replace') { + window.location.replace(to); + } else if (typeof handler === 'function') { + handler(to, config.options); + } } - if (!isRoutableLink(to) && handler !== 'href' && handler !== 'replace') { - handler = 'href'; - } - if (handler === 'href' && config.options?.replace) { - handler = 'replace'; + if (typeof to === 'number' && typeof handler === 'function') { + handler(to); } - if (handler === 'href') { - window.location.href = to; - } else if (handler === 'replace') { - window.location.replace(to); - } else if (typeof handler === 'function') { - handler(to, config.options); - } - } - if (typeof to === 'number' && typeof handler === 'function') { - handler(to); - } + }); }; /** diff --git a/ui/src/utils/request.ts b/ui/src/utils/request.ts index e8cd3b6e9..7132099d8 100644 --- a/ui/src/utils/request.ts +++ b/ui/src/utils/request.ts @@ -61,6 +61,7 @@ class Request { config: errConfig, } = error.response || {}; const { data = {}, msg = '' } = errBody || {}; + const errorObject: { code: any; msg: string; @@ -74,6 +75,7 @@ class Request { msg, data, }; + if (status === 400) { if (data?.err_type && errConfig?.passingError) { return Promise.reject(errorObject); @@ -127,6 +129,7 @@ class Request { floppyNavigation.navigateToLogin(); return Promise.reject(false); } + if (status === 403) { // Permission interception if (data?.type === 'url_expired') { @@ -173,6 +176,7 @@ class Request { errorCodeStore.getState().update('404'); return Promise.reject(false); } + if (status >= 500) { if (isIgnoredPath(IGNORE_PATH_LIST)) { return Promise.reject(false);