feat: 支持手机号登录(演示环境不开放)

1.在个人中心-安全设置中绑手机号后,才支持手机号登录
2.SMS4J(短信聚合框架,轻松集成多家短信服务,解决接入多个短信 SDK 的繁琐流程)
This commit is contained in:
2023-10-27 21:32:25 +08:00
parent 2f2905efdc
commit 4d70bc84db
27 changed files with 780 additions and 126 deletions

View File

@@ -4,8 +4,7 @@ import { UserState } from '@/store/modules/user/types';
const BASE_URL = '/auth';
export interface LoginReq {
phone?: string;
export interface AccountLoginReq {
username?: string;
password?: string;
captcha: string;
@@ -16,7 +15,7 @@ export interface LoginRes {
token: string;
}
export function accountLogin(req: LoginReq) {
export function accountLogin(req: AccountLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/account`, req);
}
@@ -29,6 +28,15 @@ export function emailLogin(req: EmailLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/email`, req);
}
export interface PhoneLoginReq {
phone: string;
captcha: string;
}
export function phoneLogin(req: PhoneLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/phone`, req);
}
export function logout() {
return axios.post(`${BASE_URL}/logout`);
}

View File

@@ -1,22 +1,19 @@
import axios from 'axios';
import qs from 'query-string';
const BASE_URL = '/common/captcha';
export interface ImageCaptchaRes {
uuid: string;
img: string;
}
export function getImageCaptcha() {
return axios.get<ImageCaptchaRes>('/common/captcha/img');
return axios.get<ImageCaptchaRes>(`${BASE_URL}/img`);
}
export interface MailCaptchaReq {
email: string;
export function getMailCaptcha(email: string) {
return axios.get(`${BASE_URL}/mail?email=${email}`);
}
export function getMailCaptcha(params: MailCaptchaReq) {
return axios.get('/common/captcha/mail', {
params,
paramsSerializer: (obj) => {
return qs.stringify(obj);
},
});
export function getSmsCaptcha(phone: string) {
return axios.get(`${BASE_URL}/sms?phone=${phone}`);
}

View File

@@ -16,31 +16,41 @@ export function uploadAvatar(data: FormData) {
return axios.post<AvatarRes>(`${BASE_URL}/avatar`, data);
}
export interface UpdateBasicInfoReq {
export interface UserBasicInfoUpdateReq {
nickname: string;
gender: number;
}
export function updateBasicInfo(req: UpdateBasicInfoReq) {
export function updateBasicInfo(req: UserBasicInfoUpdateReq) {
return axios.patch(`${BASE_URL}/basic/info`, req);
}
export interface UpdatePasswordReq {
export interface UserPasswordUpdateReq {
oldPassword: string;
newPassword: string;
}
export function updatePassword(req: UpdatePasswordReq) {
export function updatePassword(req: UserPasswordUpdateReq) {
return axios.patch(`${BASE_URL}/password`, req);
}
export interface UpdateEmailReq {
export interface UserPhoneUpdateReq {
newPhone: string;
captcha: string;
currentPassword: string;
}
export function updatePhone(req: UserPhoneUpdateReq) {
return axios.patch(`${BASE_URL}/phone`, req);
}
export interface UserEmailUpdateReq {
newEmail: string;
captcha: string;
currentPassword: string;
}
export function updateEmail(req: UpdateEmailReq) {
export function updateEmail(req: UserEmailUpdateReq) {
return axios.patch(`${BASE_URL}/email`, req);
}

View File

@@ -1,9 +1,11 @@
import { defineStore } from 'pinia';
import {
LoginReq,
AccountLoginReq,
EmailLoginReq,
PhoneLoginReq,
accountLogin as userAccountLogin,
emailLogin as userEmailLogin,
phoneLogin as userPhoneLogin,
socialLogin as userSocialLogin,
logout as userLogout,
getUserInfo,
@@ -45,7 +47,7 @@ const useUserStore = defineStore('user', {
},
// 账号登录
async accountLogin(req: LoginReq) {
async accountLogin(req: AccountLoginReq) {
try {
const res = await userAccountLogin(req);
setToken(res.data.token);
@@ -66,6 +68,17 @@ const useUserStore = defineStore('user', {
}
},
// 手机号登录
async phoneLogin(req: PhoneLoginReq) {
try {
const res = await userPhoneLogin(req);
setToken(res.data.token);
} catch (err) {
clearToken();
throw err;
}
},
// 三方账号身份登录
async socialLogin(source: string, req: any) {
try {

View File

@@ -57,7 +57,7 @@
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useUserStore } from '@/store';
import { LoginReq } from '@/api/auth';
import { AccountLoginReq } from '@/api/auth';
import { ValidatedError } from '@arco-design/web-vue';
import { encryptByRsa } from '@/utils/encrypt';
import { useRouter } from 'vue-router';
@@ -81,7 +81,7 @@
password: loginConfig.value.password,
captcha: '',
uuid: '',
} as LoginReq,
} as AccountLoginReq,
rules: {
username: [
{ required: true, message: t('login.account.error.required.username') },

View File

@@ -93,9 +93,7 @@
if (!valid) {
captchaLoading.value = true;
captchaBtnNameKey.value = 'login.captcha.ing';
getMailCaptcha({
email: form.value.email,
})
getMailCaptcha(form.value.email)
.then((res) => {
captchaLoading.value = false;
captchaDisable.value = true;
@@ -108,10 +106,7 @@
captchaTime.value
}s)`;
if (captchaTime.value <= 0) {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value = t('login.captcha.get');
captchaDisable.value = false;
resetCaptcha();
}
}, 1000);
proxy.$message.success(res.msg);

View File

@@ -6,6 +6,7 @@
layout="vertical"
size="large"
class="login-form"
@submit="handleLogin"
>
<a-form-item field="phone" hide-label>
<a-select :options="['+86']" style="flex: 1 1" default-value="+86" />
@@ -20,7 +21,7 @@
<a-input
v-model="form.captcha"
:placeholder="$t('login.phone.placeholder.captcha')"
:max-length="6"
:max-length="4"
allow-clear
style="flex: 1 1"
/>
@@ -33,8 +34,13 @@
{{ captchaBtnName }}
</a-button>
</a-form-item>
<a-button class="btn" :loading="loading" type="primary" html-type="submit"
>{{ $t('login.button') }}即将开放
<a-button
class="btn"
:loading="loading"
type="primary"
html-type="submit"
:disabled="captchaDisable"
>{{ $t('login.button') }}演示不开放
</a-button>
</a-form>
</template>
@@ -42,21 +48,28 @@
<script lang="ts" setup>
import { getCurrentInstance, ref, toRefs, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { ValidatedError } from '@arco-design/web-vue';
import { useUserStore } from '@/store';
import { LoginReq } from '@/api/auth';
import { PhoneLoginReq } from '@/api/auth';
import { getSmsCaptcha } from '@/api/common/captcha';
const { proxy } = getCurrentInstance() as any;
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false);
const captchaLoading = ref(false);
const captchaDisable = ref(false);
const captchaDisable = ref(true);
const captchaTime = ref(60);
const captchaTimer = ref();
const captchaBtnNameKey = ref('login.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
const data = reactive({
form: {} as LoginReq,
form: {
phone: '',
captcha: '',
} as PhoneLoginReq,
rules: {
phone: [
{ required: true, message: t('login.phone.error.required.phone') },
@@ -91,26 +104,71 @@
if (!valid) {
captchaLoading.value = true;
captchaBtnNameKey.value = 'login.captcha.ing';
captchaLoading.value = false;
captchaDisable.value = true;
captchaBtnNameKey.value = `${t(
'login.captcha.get'
)}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1;
captchaBtnNameKey.value = `${t('login.captcha.get')}(${
captchaTime.value
}s)`;
if (captchaTime.value <= 0) {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value = t('login.captcha.get');
captchaDisable.value = false;
}
}, 1000);
getSmsCaptcha(form.value.phone)
.then((res) => {
captchaLoading.value = false;
captchaDisable.value = true;
captchaBtnNameKey.value = `${t(
'login.captcha.get'
)}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1;
captchaBtnNameKey.value = `${t('login.captcha.get')}(${
captchaTime.value
}s)`;
if (captchaTime.value <= 0) {
resetCaptcha();
}
}, 1000);
proxy.$message.success(res.msg);
})
.catch(() => {
resetCaptcha();
captchaLoading.value = false;
});
}
});
};
/**
* 登录
*
* @param errors 表单验证错误
* @param values 表单数据
*/
const handleLogin = ({
errors,
values,
}: {
errors: Record<string, ValidatedError> | undefined;
values: Record<string, any>;
}) => {
if (loading.value) return;
if (!errors) {
loading.value = true;
userStore
.phoneLogin({
phone: values.phone,
captcha: values.captcha,
})
.then(() => {
const { redirect, ...othersQuery } = router.currentRoute.value.query;
router.push({
name: (redirect as string) || 'Workplace',
query: {
...othersQuery,
},
});
proxy.$notification.success(t('login.success'));
})
.catch(() => {
form.value.captcha = '';
})
.finally(() => {
loading.value = false;
});
}
};
</script>
<style lang="less" scoped>

View File

@@ -62,9 +62,7 @@
<a-input
v-model="form.captcha"
:placeholder="
$t(
'userCenter.securitySettings.updateEmail.form.placeholder.captcha'
)
$t('userCenter.securitySettings.form.placeholder.captcha')
"
:max-length="6"
allow-clear
@@ -107,13 +105,12 @@
import { getCurrentInstance, ref, reactive, computed } from 'vue';
import { FieldRule } from '@arco-design/web-vue';
import { getMailCaptcha } from '@/api/common/captcha';
import { updateEmail } from '@/api/system/user-center';
import { UserEmailUpdateReq, updateEmail } from '@/api/system/user-center';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store';
import { encryptByRsa } from '@/utils/encrypt';
const { proxy } = getCurrentInstance() as any;
const { t } = useI18n();
const userStore = useUserStore();
const captchaTime = ref(60);
@@ -121,13 +118,11 @@
const captchaLoading = ref(false);
const captchaDisable = ref(false);
const visible = ref(false);
const captchaBtnNameKey = ref(
'userCenter.securitySettings.updateEmail.form.sendCaptcha'
);
const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
// 表单数据
const form = reactive({
const form = reactive<UserEmailUpdateReq>({
newEmail: '',
captcha: '',
currentPassword: '',
@@ -152,9 +147,7 @@
captcha: [
{
required: true,
message: t(
'userCenter.securitySettings.updateEmail.form.error.required.captcha'
),
message: t('userCenter.securitySettings.form.error.required.captcha'),
},
],
currentPassword: [
@@ -174,8 +167,7 @@
const resetCaptcha = () => {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value =
'userCenter.securitySettings.updateEmail.form.sendCaptcha';
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.get';
captchaDisable.value = false;
};
@@ -187,29 +179,21 @@
proxy.$refs.formRef.validateField('newEmail', (valid: any) => {
if (!valid) {
captchaLoading.value = true;
captchaBtnNameKey.value =
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha';
getMailCaptcha({
email: form.newEmail,
})
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing';
getMailCaptcha(form.newEmail)
.then((res) => {
captchaLoading.value = false;
captchaDisable.value = true;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha'
'userCenter.securitySettings.captcha.get'
)}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha'
'userCenter.securitySettings.captcha.get'
)}(${captchaTime.value}s)`;
if (captchaTime.value <= 0) {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value = t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha'
);
captchaDisable.value = false;
resetCaptcha();
}
}, 1000);
proxy.$message.success(res.msg);

View File

@@ -18,18 +18,232 @@
</a-typography-paragraph>
</div>
<div class="operation">
<a-link disabled :title="$t('userCenter.securitySettings.button.update')">
<a-link
:title="$t('userCenter.securitySettings.button.update')"
@click="toUpdate"
>
{{ $t('userCenter.securitySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
<a-modal
:title="$t('userCenter.securitySettings.updatePhone.modal.title')"
:visible="visible"
:mask-closable="false"
:esc-to-close="false"
@ok="handleUpdate"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="form" :rules="rules" size="large">
<a-form-item
:label="
$t('userCenter.securitySettings.updatePhone.form.label.newPhone')
"
field="newPhone"
>
<a-input
v-model="form.newPhone"
:placeholder="
$t(
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone'
)
"
allow-clear
/>
</a-form-item>
<a-form-item
:label="
$t('userCenter.securitySettings.updatePhone.form.label.captcha')
"
field="captcha"
>
<a-input
v-model="form.captcha"
:placeholder="
$t('userCenter.securitySettings.form.placeholder.captcha')
"
:max-length="4"
allow-clear
style="width: 80%"
/>
<a-button
:loading="captchaLoading"
type="primary"
:disabled="captchaDisable"
class="captcha-btn"
@click="handleSendCaptcha"
>
{{ captchaBtnName }}
</a-button>
</a-form-item>
<a-form-item
:label="
$t(
'userCenter.securitySettings.updatePhone.form.label.currentPassword'
)
"
field="currentPassword"
>
<a-input-password
v-model="form.currentPassword"
:placeholder="
$t(
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword'
)
"
:max-length="32"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { getCurrentInstance, ref, reactive, computed } from 'vue';
import { FieldRule } from '@arco-design/web-vue';
import { getSmsCaptcha } from '@/api/common/captcha';
import { UserPhoneUpdateReq, updatePhone } from '@/api/system/user-center';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store';
import { encryptByRsa } from '@/utils/encrypt';
const { proxy } = getCurrentInstance() as any;
const { t } = useI18n();
const userStore = useUserStore();
const captchaTime = ref(60);
const captchaTimer = ref();
const captchaLoading = ref(false);
const captchaDisable = ref(true);
const visible = ref(false);
const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
// 表单数据
const form = reactive<UserPhoneUpdateReq>({
newPhone: '',
captcha: '',
currentPassword: '',
});
// 表单验证规则
const rules = computed((): Record<string, FieldRule[]> => {
return {
newPhone: [
{
required: true,
message: t(
'userCenter.securitySettings.updatePhone.form.error.required.newPhone'
),
},
{
match: /^1[3-9]\d{9}$/,
message: t(
'userCenter.securitySettings.updatePhone.form.error.match.newPhone'
),
},
],
captcha: [
{
required: true,
message: t('userCenter.securitySettings.form.error.required.captcha'),
},
],
currentPassword: [
{
required: true,
message: t(
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword'
),
},
],
};
});
/**
* 重置验证码
*/
const resetCaptcha = () => {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.get';
captchaDisable.value = false;
};
/**
* 发送验证码
*/
const handleSendCaptcha = () => {
if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('newPhone', (valid: any) => {
if (!valid) {
captchaLoading.value = true;
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing';
getSmsCaptcha(form.newPhone)
.then((res) => {
captchaLoading.value = false;
captchaDisable.value = true;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get'
)}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get'
)}(${captchaTime.value}s)`;
if (captchaTime.value <= 0) {
resetCaptcha();
}
}, 1000);
proxy.$message.success(res.msg);
})
.catch(() => {
resetCaptcha();
captchaLoading.value = false;
});
}
});
};
/**
* 取消
*/
const handleCancel = () => {
visible.value = false;
proxy.$refs.formRef.resetFields();
resetCaptcha();
};
/**
* 修改
*/
const handleUpdate = () => {
proxy.$refs.formRef.validate((valid: any) => {
if (!valid) {
updatePhone({
newPhone: form.newPhone,
captcha: form.captcha,
currentPassword: encryptByRsa(form.currentPassword) || '',
}).then((res) => {
handleCancel();
userStore.getInfo();
proxy.$message.success(res.msg);
});
}
});
};
/**
* 打开修改对话框
*/
const toUpdate = () => {
visible.value = true;
};
</script>
<style scoped lang="less"></style>
<style scoped lang="less">
.captcha-btn {
margin-left: 5px;
}
</style>

View File

@@ -74,6 +74,24 @@ export default {
'It is used to receive messages, verify identity, and support mobile phone verification code login after binding',
'userCenter.securitySettings.phone.content': 'Unbound',
'userCenter.securitySettings.updatePhone.modal.title': 'Update phone',
'userCenter.securitySettings.updatePhone.form.label.newPhone': 'New phone',
'userCenter.securitySettings.updatePhone.form.label.captcha': 'Captcha',
'userCenter.securitySettings.updatePhone.form.label.currentPassword':
'Current password',
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone':
'Please enter new phone',
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword':
'Please enter current password',
'userCenter.securitySettings.updatePhone.form.error.required.newPhone':
'Please enter new phone',
'userCenter.securitySettings.updatePhone.form.error.match.newPhone':
'Please enter the correct phone',
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword':
'Please enter current password',
// update-email
'userCenter.securitySettings.email.label': 'Email',
'userCenter.securitySettings.email.tip':
@@ -85,16 +103,9 @@ export default {
'userCenter.securitySettings.updateEmail.form.label.captcha': 'Captcha',
'userCenter.securitySettings.updateEmail.form.label.currentPassword':
'Current password',
'userCenter.securitySettings.updateEmail.form.sendCaptcha': 'Send captcha',
'userCenter.securitySettings.updateEmail.form.reSendCaptcha':
'Resend captcha',
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha':
'Sending...',
'userCenter.securitySettings.updateEmail.form.placeholder.newEmail':
'Please enter new email',
'userCenter.securitySettings.updateEmail.form.placeholder.captcha':
'Please enter email captcha',
'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword':
'Please enter current password',
@@ -102,8 +113,6 @@ export default {
'Please enter new email',
'userCenter.securitySettings.updateEmail.form.error.match.newEmail':
'Please enter the correct email',
'userCenter.securitySettings.updateEmail.form.error.required.captcha':
'Please enter email captcha',
'userCenter.securitySettings.updateEmail.form.error.required.currentPassword':
'Please enter current password',
@@ -115,4 +124,10 @@ export default {
'userCenter.securitySettings.content.hasBeenSet': 'Has been set',
'userCenter.securitySettings.button.update': 'Update',
'userCenter.securitySettings.captcha.get': 'Get captcha',
'userCenter.securitySettings.captcha.ing': 'Sending...',
'userCenter.securitySettings.form.placeholder.captcha':
'Please enter captcha',
'userCenter.securitySettings.form.error.required.captcha':
'Please enter captcha',
};

View File

@@ -70,6 +70,25 @@ export default {
'用于接收消息、验证身份,绑定后可支持手机验证码登录',
'userCenter.securitySettings.phone.content': '未绑定',
'userCenter.securitySettings.updatePhone.modal.title': '修改手机号',
'userCenter.securitySettings.updatePhone.form.label.newPhone': '新手机号',
'userCenter.securitySettings.updatePhone.form.label.captcha': '验证码',
'userCenter.securitySettings.updatePhone.form.label.currentPassword':
'当前密码',
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone':
'请输入新手机号',
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword':
'请输入当前密码',
'userCenter.securitySettings.updatePhone.form.error.required.newPhone':
'请输入新手机号',
'userCenter.securitySettings.updatePhone.form.error.match.newPhone':
'请输入正确的手机号',
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword':
'请输入当前密码',
// update-email
'userCenter.securitySettings.email.label': '安全邮箱',
'userCenter.securitySettings.email.tip':
@@ -81,15 +100,9 @@ export default {
'userCenter.securitySettings.updateEmail.form.label.captcha': '验证码',
'userCenter.securitySettings.updateEmail.form.label.currentPassword':
'当前密码',
'userCenter.securitySettings.updateEmail.form.sendCaptcha': '发送验证码',
'userCenter.securitySettings.updateEmail.form.reSendCaptcha': '重新发送',
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha':
'发送中...',
'userCenter.securitySettings.updateEmail.form.placeholder.newEmail':
'请输入新邮箱',
'userCenter.securitySettings.updateEmail.form.placeholder.captcha':
'请输入邮箱验证码',
'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword':
'请输入当前密码',
@@ -97,8 +110,6 @@ export default {
'请输入新邮箱',
'userCenter.securitySettings.updateEmail.form.error.match.newEmail':
'请输入正确的邮箱',
'userCenter.securitySettings.updateEmail.form.error.required.captcha':
'请输入邮箱验证码',
'userCenter.securitySettings.updateEmail.form.error.required.currentPassword':
'请输入当前密码',
@@ -109,4 +120,8 @@ export default {
'userCenter.securitySettings.content.hasBeenSet': '已设置',
'userCenter.securitySettings.button.update': '修改',
'userCenter.securitySettings.captcha.get': '获取验证码',
'userCenter.securitySettings.captcha.ing': '发送中...',
'userCenter.securitySettings.form.placeholder.captcha': '请输入验证码',
'userCenter.securitySettings.form.error.required.captcha': '请输入验证码',
};