Files
oxygen-ui/src/pages/User/index.tsx

644 lines
31 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 Icon from '@ant-design/icons'
import '@/assets/css/pages/user/index.scss'
import {
COLOR_BACKGROUND,
COLOR_ERROR,
COLOR_PRODUCTION,
DATABASE_UPDATE_SUCCESS,
PERMISSION_ACCESS_DENIED,
PERMISSION_LOGIN_USERNAME_PASSWORD_ERROR
} from '@/constants/common.constants'
import { utcToLocalTime } from '@/util/datetime'
import { getUserInfo, removeToken } from '@/util/auth'
import { r_sys_user_info_change_password, r_sys_user_info_update } from '@/services/system'
import {
r_auth_two_factor_create,
r_auth_two_factor_remove,
r_auth_two_factor_validate
} from '@/services/auth'
import { r_api_avatar_random_base64 } from '@/services/api/avatar'
import FitFullscreen from '@/components/common/FitFullscreen'
import Card from '@/components/common/Card'
import FlexBox from '@/components/common/FlexBox'
import HideScrollbar from '@/components/common/HideScrollbar'
const User = () => {
const [modal, contextHolder] = AntdModal.useModal()
const [form] = AntdForm.useForm<UserInfoUpdateParam>()
const formValues = AntdForm.useWatch([], form)
const [twoFactorForm] = AntdForm.useForm<{ twoFactorCode: string }>()
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmittable, setIsSubmittable] = useState(false)
const [isGettingAvatar, setIsGettingAvatar] = useState(false)
const [avatar, setAvatar] = useState('')
const [userWithPowerInfoVo, setUserWithPowerInfoVo] = useState<UserWithPowerInfoVo>()
const [changePasswordForm] = AntdForm.useForm<UserChangePasswordParam>()
const handleOnCopyToClipboard = (username?: string) => {
return username
? () => {
void navigator.clipboard
.writeText(new URL(`/store/${username}`, import.meta.env.VITE_UI_URL).href)
.then(() => {
void message.success('已复制到剪切板')
})
}
: undefined
}
const handleOnReset = () => {
getProfile()
}
const handleOnSave = () => {
if (isSubmitting) {
return
}
setIsSubmitting(true)
void message.loading({ content: '保存中', key: 'LOADING', duration: 0 })
void r_sys_user_info_update({ avatar, nickname: formValues.nickname })
.then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_UPDATE_SUCCESS:
void message.success('保存成功')
getProfile()
break
default:
void message.error('保存失败,请稍后重试')
}
})
.finally(() => {
setIsSubmitting(false)
void message.destroy('LOADING')
})
}
const handleOnChangeAvatar = () => {
if (isLoading || isGettingAvatar) {
return
}
setIsGettingAvatar(true)
void r_api_avatar_random_base64()
.then((res) => {
const response = res.data
if (response.success) {
response.data?.base64 && setAvatar(response.data.base64)
}
})
.finally(() => {
setIsGettingAvatar(false)
})
}
const handleOnChangePassword = () => {
changePasswordForm.resetFields()
void modal.confirm({
centered: true,
maskClosable: true,
icon: <></>,
title: (
<>
<Icon
style={{ color: COLOR_PRODUCTION, marginRight: 10 }}
component={IconOxygenSetting}
/>
</>
),
footer: (_, { OkBtn, CancelBtn }) => (
<>
<OkBtn />
<CancelBtn />
</>
),
content: (
<AntdForm
form={changePasswordForm}
style={{ marginTop: 20 }}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
ref={() => {
setTimeout(() => {
changePasswordForm?.getFieldInstance('originalPassword').focus()
}, 50)
}}
>
<AntdForm.Item
name={'originalPassword'}
label={'原密码'}
labelAlign={'right'}
rules={[{ required: true, whitespace: true }]}
>
<AntdInput.Password placeholder={'请输入原密码'} />
</AntdForm.Item>
<AntdForm.Item
name={'newPassword'}
label={'新密码'}
labelAlign={'right'}
rules={[
{ required: true, whitespace: true },
{ min: 10, message: '密码至少为10位' },
{ max: 30, message: '密码最多为30位' }
]}
>
<AntdInput.Password placeholder={'请输入新密码'} />
</AntdForm.Item>
<AntdForm.Item
name={'newPasswordConfirm'}
label={'确认密码'}
labelAlign={'right'}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次密码输入必须一致'))
}
})
]}
>
<AntdInput.Password placeholder={'请确认密码'} />
</AntdForm.Item>
</AntdForm>
),
onOk: () =>
changePasswordForm.validateFields().then(
() => {
return new Promise<void>((resolve, reject) => {
void r_sys_user_info_change_password({
originalPassword: changePasswordForm.getFieldValue(
'originalPassword'
) as string,
newPassword: changePasswordForm.getFieldValue(
'newPassword'
) as string
}).then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_UPDATE_SUCCESS:
void message.success('密码修改成功,请重新登录')
removeToken()
notification.info({
message: '已退出登录',
icon: (
<Icon
component={IconOxygenExit}
style={{ color: COLOR_ERROR }}
/>
)
})
setTimeout(() => {
window.location.reload()
}, 1500)
resolve()
break
case PERMISSION_ACCESS_DENIED:
void message.error('拒绝访问')
resolve()
break
case PERMISSION_LOGIN_USERNAME_PASSWORD_ERROR:
void message.warning('原密码错误,请重新输入')
reject(response.msg)
break
default:
void message.error('出错了,请稍后重试')
resolve()
}
})
})
},
() => {
return new Promise((_, reject) => {
reject('输入有误')
})
}
)
})
}
const handleOnChangeTwoFactor = (enable: boolean) => {
return () => {
twoFactorForm.resetFields()
if (enable) {
void modal.confirm({
centered: true,
maskClosable: true,
focusTriggerAfterClose: false,
title: '双因素',
footer: (_, { OkBtn, CancelBtn }) => (
<>
<OkBtn />
<CancelBtn />
</>
),
content: '确定解除双因素?',
onOk: () => {
void modal.confirm({
centered: true,
maskClosable: true,
title: '解除双因素',
footer: (_, { OkBtn, CancelBtn }) => (
<>
<OkBtn />
<CancelBtn />
</>
),
content: (
<>
<AntdForm
form={twoFactorForm}
ref={() => {
setTimeout(() => {
twoFactorForm
?.getFieldInstance('twoFactorCode')
.focus()
}, 50)
}}
>
<AntdForm.Item
name={'twoFactorCode'}
label={'验证码'}
style={{ marginTop: 10 }}
rules={[{ required: true, whitespace: true, len: 6 }]}
>
<AntdInput
showCount
maxLength={6}
autoComplete={'off'}
placeholder={'请输入验证码'}
/>
</AntdForm.Item>
</AntdForm>
</>
),
onOk: () =>
twoFactorForm.validateFields().then(
() => {
return new Promise<void>((resolve) => {
void r_auth_two_factor_remove({
code: twoFactorForm.getFieldValue(
'twoFactorCode'
) as string
})
.then((res) => {
const response = res.data
if (response.success) {
void message.success('解绑成功')
getProfile()
} else {
void message.error('解绑失败,请稍后重试')
}
})
.finally(() => {
resolve()
})
})
},
() => {
return new Promise((_, reject) => {
reject('输入有误')
})
}
)
})
}
})
} else {
if (isLoading) {
return
}
setIsLoading(true)
void message.loading({ content: '加载中', key: 'LOADING', duration: 0 })
void r_auth_two_factor_create()
.then((res) => {
message.destroy('LOADING')
const response = res.data
if (response.success) {
void modal.confirm({
centered: true,
maskClosable: true,
title: '绑定双因素',
footer: (_, { OkBtn, CancelBtn }) => (
<>
<OkBtn />
<CancelBtn />
</>
),
content: (
<>
<AntdImage
src={`data:image/svg+xml;base64,${response.data?.qrCodeSVGBase64}`}
alt={'Two-factor'}
preview={false}
/>
<AntdTag style={{ whiteSpace: 'normal' }}>
使APPeg. Microsoft Authenticator, Google
Authenticator
</AntdTag>
<AntdForm
form={twoFactorForm}
ref={() => {
setTimeout(() => {
twoFactorForm
?.getFieldInstance('twoFactorCode')
.focus()
}, 50)
}}
>
<AntdForm.Item
name={'twoFactorCode'}
label={'验证码'}
style={{ marginTop: 10, marginRight: 30 }}
rules={[
{ required: true, whitespace: true, len: 6 }
]}
>
<AntdInput
showCount
maxLength={6}
autoComplete={'off'}
placeholder={'请输入验证码'}
/>
</AntdForm.Item>
</AntdForm>
</>
),
onOk: () =>
twoFactorForm.validateFields().then(
() => {
return new Promise<void>((resolve) => {
void r_auth_two_factor_validate({
code: twoFactorForm.getFieldValue(
'twoFactorCode'
) as string
})
.then((res) => {
const response = res.data
if (response.success) {
void message.success('绑定成功')
getProfile()
} else {
void message.error(
'绑定失败,请稍后重试'
)
}
})
.finally(() => {
resolve()
})
})
},
() => {
return new Promise((_, reject) => {
reject('输入有误')
})
}
)
})
} else {
void message.error('获取双因素绑定二维码失败,请稍后重试')
}
})
.catch(() => {
message.destroy('LOADING')
})
.finally(() => {
setIsLoading(false)
})
}
}
}
const getProfile = () => {
if (isLoading) {
return
}
setIsLoading(true)
void getUserInfo(true)
.then((userWithPowerInfoVo) => {
setAvatar(userWithPowerInfoVo.userInfo.avatar)
form.setFieldValue('nickname', userWithPowerInfoVo.userInfo.nickname)
setUserWithPowerInfoVo(userWithPowerInfoVo)
void form.validateFields()
})
.finally(() => {
setIsLoading(false)
})
}
useEffect(() => {
form.validateFields({ validateOnly: true }).then(
() => {
setIsSubmittable(true)
},
() => {
setIsSubmittable(false)
}
)
}, [formValues])
useEffect(() => {
getProfile()
}, [])
return (
<>
<FitFullscreen data-component={'user'}>
<HideScrollbar
isShowVerticalScrollbar
autoHideWaitingTime={1000}
className={'root-content'}
>
<Card>
<FlexBox className={'info'} direction={'horizontal'}>
<AntdTooltip title={'点击获取新头像'}>
<div className={'avatar-box'}>
<AntdAvatar
src={
<img
src={`data:image/png;base64,${avatar}`}
alt={'Avatar'}
/>
}
size={144}
style={{
background: COLOR_BACKGROUND,
cursor: 'pointer'
}}
className={'avatar'}
onClick={handleOnChangeAvatar}
/>
</div>
</AntdTooltip>
<FlexBox className={'info-name'}>
<div className={'nickname'}>
{userWithPowerInfoVo?.userInfo.nickname}
</div>
<a
className={'url'}
onClick={handleOnCopyToClipboard(userWithPowerInfoVo?.username)}
>
{userWithPowerInfoVo?.username &&
new URL(
`/store/${userWithPowerInfoVo.username}`,
import.meta.env.VITE_UI_URL
).href}
<Icon component={IconOxygenCopy} />
</a>
</FlexBox>
</FlexBox>
<FlexBox className={'title'}>
<FlexBox direction={'horizontal'} className={'content'}>
<div className={'text'}></div>
<FlexBox className={'operation'} direction={'horizontal'}>
<AntdButton onClick={handleOnReset} loading={isLoading}>
</AntdButton>
<AntdButton
onClick={handleOnSave}
type={'primary'}
disabled={isLoading || !isSubmittable}
>
</AntdButton>
</FlexBox>
</FlexBox>
</FlexBox>
<div className={'divide'} />
<FlexBox className={'profile table'}>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdForm form={form}>
<AntdForm.Item
name={'nickname'}
rules={[
{ required: true, whitespace: true },
{ min: 3, message: '昵称至少为3个字符' }
]}
style={{ marginBottom: 0 }}
>
<AntdInput
maxLength={20}
showCount
disabled={isLoading}
placeholder={'请输入昵称'}
/>
</AntdForm.Item>
</AntdForm>
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdInput disabled value={userWithPowerInfoVo?.username} />
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdInput
disabled
value={userWithPowerInfoVo?.userInfo.email}
/>
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdInput
disabled
value={utcToLocalTime(
userWithPowerInfoVo?.createTime ?? ''
)}
/>
</div>
</FlexBox>
</FlexBox>
<div className={'divide'} />
<FlexBox className={'security table'}>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}> IP</div>
<div className={'input'}>
<AntdInput disabled value={userWithPowerInfoVo?.lastLoginIp} />
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdInput
disabled
value={utcToLocalTime(
userWithPowerInfoVo?.lastLoginTime ?? ''
)}
/>
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdSpace.Compact>
<AntdInput disabled value={'********'} />
<AntdButton
type={'primary'}
title={'更改密码'}
disabled={isLoading}
onClick={handleOnChangePassword}
>
<Icon component={IconOxygenRefresh} />
</AntdButton>
</AntdSpace.Compact>
</div>
</FlexBox>
<FlexBox className={'row'} direction={'horizontal'}>
<div className={'label'}></div>
<div className={'input'}>
<AntdSpace.Compact>
<AntdInput
disabled
style={{
color: userWithPowerInfoVo?.twoFactor
? COLOR_PRODUCTION
: undefined
}}
value={
userWithPowerInfoVo?.twoFactor ? '已设置' : '未设置'
}
/>
<AntdButton
type={'primary'}
title={userWithPowerInfoVo?.twoFactor ? '解绑' : '绑定'}
disabled={isLoading}
onClick={handleOnChangeTwoFactor(
userWithPowerInfoVo?.twoFactor ?? false
)}
>
<Icon
component={
userWithPowerInfoVo?.twoFactor
? IconOxygenUnlock
: IconOxygenLock
}
/>
</AntdButton>
</AntdSpace.Compact>
</div>
</FlexBox>
</FlexBox>
</Card>
</HideScrollbar>
</FitFullscreen>
{contextHolder}
</>
)
}
export default User