mirror of
https://github.com/certd/certd.git
synced 2026-05-16 05:07:32 +08:00
perf(用户资料): 新增手机号邮箱绑定功能
实现用户邮箱和手机号的绑定与修改功能,包括: 1. 添加联系方式绑定API接口 2. 实现身份验证流程 3. 添加前端绑定对话框组件 4. 完善用户资料页面的联系方式展示和编辑入口 5. 添加联系方式冲突检测逻辑 6. 实现验证码校验功能
This commit is contained in:
@@ -23,6 +23,37 @@ export async function UpdateProfile(form: any) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function GetContactCapability() {
|
||||
return await request({
|
||||
url: "/mine/contact/capability",
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function UpdateMobile(form: any) {
|
||||
return await request({
|
||||
url: "/mine/contact/mobile",
|
||||
method: "POST",
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function VerifyContactIdentity(form: any) {
|
||||
return await request({
|
||||
url: "/mine/contact/verifyIdentity",
|
||||
method: "POST",
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function UpdateEmail(form: any) {
|
||||
return await request({
|
||||
url: "/mine/contact/email",
|
||||
method: "POST",
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GetOauthBounds() {
|
||||
return await request({
|
||||
url: "/oauth/bounds",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineComponent } from "vue";
|
||||
import SmsCode from "/@/views/framework/login/sms-code.vue";
|
||||
import EmailCode from "/@/views/framework/register/email-code.vue";
|
||||
|
||||
export const ContactCodeInput = defineComponent({
|
||||
name: "ContactCodeInput",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const onChange = (value: string) => emit("update:modelValue", value);
|
||||
return () => {
|
||||
if (props.type === "email") {
|
||||
return <EmailCode value={props.modelValue} captcha={props.form.contactCaptcha} email={props.form.email} verificationType="bindEmail" onUpdate:value={onChange} />;
|
||||
}
|
||||
return <SmsCode value={props.modelValue} captcha={props.form.contactCaptcha} mobile={props.form.mobile} phoneCode={props.form.phoneCode} verificationType="bindMobile" onUpdate:value={onChange} />;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineComponent } from "vue";
|
||||
import SmsCode from "/@/views/framework/login/sms-code.vue";
|
||||
import EmailCode from "/@/views/framework/register/email-code.vue";
|
||||
|
||||
export const IdentityCodeInput = defineComponent({
|
||||
name: "IdentityCodeInput",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const onChange = (value: string) => emit("update:modelValue", value);
|
||||
return () => {
|
||||
if (props.form.identityType === "email") {
|
||||
return <EmailCode value={props.modelValue} captcha={props.form.identityCaptcha} email={props.userInfo.email} verificationType="contactIdentity" onUpdate:value={onChange} />;
|
||||
}
|
||||
return (
|
||||
<SmsCode value={props.modelValue} captcha={props.form.identityCaptcha} mobile={props.userInfo.mobile} phoneCode={props.userInfo.phoneCode || "86"} verificationType="contactIdentity" onUpdate:value={onChange} />
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
// useUserProfile, 获取 openEditProfileDialog ,参考 useTemplate方法
|
||||
import { useFormWrapper } from "@fast-crud/fast-crud";
|
||||
import { ref } from "vue";
|
||||
import { cloneDeep, merge } from "lodash-es";
|
||||
import { compute, dict } from "@fast-crud/fast-crud";
|
||||
|
||||
// 假设的 API 导入
|
||||
import * as userProfileApi from "./api";
|
||||
import { useUserStore } from "/@/store/user";
|
||||
import { useI18n } from "/src/locales";
|
||||
import CaptchaInput from "/@/components/captcha/captcha-input.vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { ContactCodeInput } from "./contact-code-input";
|
||||
import { IdentityCodeInput } from "./identity-code-input";
|
||||
import { useFormDialog } from "/@/use/use-dialog";
|
||||
|
||||
/**
|
||||
* 获取用户资料编辑相关功能
|
||||
* @returns {{openEditProfileDialog: openEditProfileDialog}}
|
||||
*/
|
||||
export function useUserProfile() {
|
||||
const { openCrudFormDialog } = useFormWrapper();
|
||||
const wrapperRef = ref();
|
||||
const { openFormDialog } = useFormDialog();
|
||||
async function openEditProfileDialog(req: { onUpdated?: (ctx: any) => void }) {
|
||||
const detail = await userProfileApi.getMineInfo();
|
||||
if (!detail) {
|
||||
@@ -24,31 +26,28 @@ export function useUserProfile() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userProfileFormRef = ref();
|
||||
async function doSubmit(opts: { form: any }) {
|
||||
const form = opts.form;
|
||||
async function doSubmit(form: any) {
|
||||
const { id } = await userProfileApi.UpdateProfile(form);
|
||||
if (req.onUpdated) {
|
||||
req.onUpdated({ id });
|
||||
}
|
||||
}
|
||||
|
||||
const crudOptions: any = {
|
||||
form: {
|
||||
doSubmit,
|
||||
wrapper: {
|
||||
title: `编辑用户资料`,
|
||||
width: 1100,
|
||||
onOpened(opts: { form: any }) {
|
||||
merge(opts.form, detail);
|
||||
},
|
||||
},
|
||||
await openFormDialog({
|
||||
title: `编辑用户资料`,
|
||||
wrapper: {
|
||||
width: 600,
|
||||
},
|
||||
initialForm: detail,
|
||||
onSubmit: doSubmit,
|
||||
columns: {
|
||||
nickName: {
|
||||
title: t("certd.nickName"),
|
||||
type: "text",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
component: {
|
||||
placeholder: t("certd.nickName"),
|
||||
},
|
||||
@@ -71,6 +70,9 @@ export function useUserProfile() {
|
||||
},
|
||||
},
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
component: {
|
||||
vModel: "modelValue",
|
||||
valueType: "key",
|
||||
@@ -98,10 +100,7 @@ export function useUserProfile() {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = await openCrudFormDialog({ crudOptions });
|
||||
wrapperRef.value = wrapper;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -110,26 +109,20 @@ export function useUserProfile() {
|
||||
}
|
||||
|
||||
export function usePasskeyRegister() {
|
||||
const { openCrudFormDialog } = useFormWrapper();
|
||||
const wrapperRef = ref();
|
||||
const { openFormDialog } = useFormDialog();
|
||||
async function openRegisterDialog(req: { onSubmit?: (ctx: any) => void }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const deviceNameRef = ref();
|
||||
|
||||
const crudOptions: any = {
|
||||
form: {
|
||||
wrapper: {
|
||||
title: t("authentication.registerPasskey"),
|
||||
width: 500,
|
||||
onOpened(opts: { form: any }) {
|
||||
opts.form.deviceName = "";
|
||||
},
|
||||
},
|
||||
onSubmit: req.onSubmit,
|
||||
afterSubmit: null,
|
||||
onSuccess: null,
|
||||
await openFormDialog({
|
||||
title: t("authentication.registerPasskey"),
|
||||
wrapper: {
|
||||
width: 500,
|
||||
},
|
||||
initialForm: {
|
||||
deviceName: "",
|
||||
},
|
||||
onSubmit: async (form: any) => {
|
||||
await req.onSubmit?.({ form });
|
||||
},
|
||||
columns: {
|
||||
deviceName: {
|
||||
@@ -147,15 +140,229 @@ export function usePasskeyRegister() {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = await openCrudFormDialog({ crudOptions });
|
||||
wrapperRef.value = wrapper;
|
||||
|
||||
return wrapper;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openRegisterDialog,
|
||||
};
|
||||
}
|
||||
|
||||
export function useContactBind() {
|
||||
const { openFormDialog } = useFormDialog();
|
||||
|
||||
async function openContactBindDialog(req: { type: "mobile" | "email"; userInfo: any; contactCapability: { smsEnabled?: boolean }; onUpdated?: () => Promise<void> | void }) {
|
||||
const methods = [{ label: "密码", value: "password" }];
|
||||
if (req.userInfo.email) {
|
||||
methods.push({ label: "邮箱", value: "email" });
|
||||
}
|
||||
if (req.contactCapability.smsEnabled && req.userInfo.mobile) {
|
||||
methods.push({ label: "手机号", value: "mobile" });
|
||||
}
|
||||
|
||||
async function openChangeDialog(identityValidationCode: string) {
|
||||
const isMobile = req.type === "mobile";
|
||||
await openFormDialog({
|
||||
title: isMobile ? (req.userInfo.mobile ? "修改手机号" : "绑定手机号") : req.userInfo.email ? "修改邮箱" : "绑定邮箱",
|
||||
wrapper: {
|
||||
width: 560,
|
||||
},
|
||||
initialForm: {
|
||||
phoneCode: req.userInfo.phoneCode || "86",
|
||||
mobile: req.userInfo.mobile || "",
|
||||
email: req.userInfo.email || "",
|
||||
contactCaptcha: null,
|
||||
contactValidateCode: "",
|
||||
},
|
||||
async onSubmit(form: any) {
|
||||
if (isMobile) {
|
||||
await userProfileApi.UpdateMobile({
|
||||
phoneCode: form.phoneCode,
|
||||
mobile: form.mobile,
|
||||
validateCode: form.contactValidateCode,
|
||||
identityValidationCode,
|
||||
});
|
||||
} else {
|
||||
await userProfileApi.UpdateEmail({
|
||||
email: form.email,
|
||||
validateCode: form.contactValidateCode,
|
||||
identityValidationCode,
|
||||
});
|
||||
}
|
||||
message.success("绑定信息已更新");
|
||||
await req.onUpdated?.();
|
||||
},
|
||||
columns: {
|
||||
phoneCode: {
|
||||
title: "区号",
|
||||
type: "text",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: isMobile,
|
||||
component: {
|
||||
placeholder: "区号",
|
||||
},
|
||||
rules: [{ required: isMobile, message: "请输入区号" }],
|
||||
},
|
||||
},
|
||||
mobile: {
|
||||
title: "手机号",
|
||||
type: "text",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: isMobile,
|
||||
component: {
|
||||
placeholder: "请输入手机号",
|
||||
},
|
||||
rules: [
|
||||
{ required: isMobile, message: "请输入手机号" },
|
||||
{ pattern: /^\d{4,20}$/, message: "请输入正确的手机号" },
|
||||
],
|
||||
},
|
||||
},
|
||||
email: {
|
||||
title: "邮箱",
|
||||
type: "text",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: !isMobile,
|
||||
component: {
|
||||
placeholder: "请输入邮箱",
|
||||
},
|
||||
rules: [
|
||||
{ required: !isMobile, message: "请输入邮箱" },
|
||||
{ type: "email", message: "请输入正确的邮箱" },
|
||||
],
|
||||
},
|
||||
},
|
||||
contactCaptcha: {
|
||||
title: "图形验证码",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
component: {
|
||||
name: CaptchaInput,
|
||||
vModel: "modelValue",
|
||||
},
|
||||
rules: [{ required: true, message: "请完成图形验证码" }],
|
||||
},
|
||||
},
|
||||
contactValidateCode: {
|
||||
title: isMobile ? "新手机号验证码" : "新邮箱验证码",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
component: {
|
||||
name: ContactCodeInput,
|
||||
vModel: "modelValue",
|
||||
form: compute(({ form }) => form),
|
||||
type: req.type,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入验证码" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await openFormDialog({
|
||||
title: "验证本人操作",
|
||||
wrapper: {
|
||||
width: 520,
|
||||
},
|
||||
initialForm: {
|
||||
identityType: "password",
|
||||
identityPassword: "",
|
||||
identityCaptcha: null,
|
||||
identityValidateCode: "",
|
||||
},
|
||||
async onSubmit(form: any) {
|
||||
const res = await userProfileApi.VerifyContactIdentity({
|
||||
identityType: form.identityType,
|
||||
identityPassword: form.identityPassword,
|
||||
identityValidateCode: form.identityValidateCode,
|
||||
});
|
||||
await openChangeDialog(res.validationCode);
|
||||
},
|
||||
columns: {
|
||||
identityType: {
|
||||
title: "验证方式",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
component: {
|
||||
name: "fs-dict-radio",
|
||||
vModel: "value",
|
||||
dict: dict({
|
||||
data: methods,
|
||||
}),
|
||||
},
|
||||
rules: [{ required: true, message: "请选择验证方式" }],
|
||||
valueChange({ form }: { form: any }) {
|
||||
form.identityPassword = "";
|
||||
form.identityCaptcha = null;
|
||||
form.identityValidateCode = "";
|
||||
},
|
||||
},
|
||||
},
|
||||
identityPassword: {
|
||||
title: "登录密码",
|
||||
type: "password",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: compute(({ form }) => form.identityType === "password"),
|
||||
component: {
|
||||
placeholder: "请输入登录密码",
|
||||
},
|
||||
rules: [{ required: true, message: "请输入登录密码" }],
|
||||
},
|
||||
},
|
||||
identityCaptcha: {
|
||||
title: "图形验证码",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: compute(({ form }) => form.identityType !== "password"),
|
||||
component: {
|
||||
name: CaptchaInput,
|
||||
vModel: "modelValue",
|
||||
},
|
||||
rules: [{ required: true, message: "请完成图形验证码" }],
|
||||
},
|
||||
},
|
||||
identityValidateCode: {
|
||||
title: "验证码",
|
||||
form: {
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
show: compute(({ form }) => form.identityType !== "password"),
|
||||
component: {
|
||||
name: IdentityCodeInput,
|
||||
vModel: "modelValue",
|
||||
form: compute(({ form }) => form),
|
||||
userInfo: req.userInfo,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入验证码" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openContactBindDialog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,13 +25,19 @@
|
||||
<span class="tag-icon">👤</span>
|
||||
{{ userInfo.username }}
|
||||
</a-tag>
|
||||
<a-tag v-if="userInfo.email" color="green" class="detail-tag">
|
||||
<a-tag color="green" class="detail-tag">
|
||||
<span class="tag-icon">📧</span>
|
||||
{{ userInfo.email }}
|
||||
<span>{{ userInfo.email || "未绑定邮箱" }}</span>
|
||||
<a-button type="text" size="small" class="detail-edit-btn" title="修改邮箱" @click.stop="openBindContact('email')">
|
||||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||||
</a-button>
|
||||
</a-tag>
|
||||
<a-tag v-if="userInfo.mobile" color="purple" class="detail-tag">
|
||||
<a-tag v-if="contactCapability.smsEnabled" color="purple" class="detail-tag">
|
||||
<span class="tag-icon">📱</span>
|
||||
{{ userInfo.mobile }}
|
||||
<span>{{ userInfo.mobile || "未绑定手机号" }}</span>
|
||||
<a-button type="text" size="small" class="detail-edit-btn" title="修改手机号" @click.stop="openBindContact('mobile')">
|
||||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||||
</a-button>
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +145,7 @@ import * as api from "./api";
|
||||
import { computed, onMounted, Ref, ref } from "vue";
|
||||
import ChangePasswordButton from "/@/views/certd/mine/change-password-button.vue";
|
||||
import { useI18n } from "/src/locales";
|
||||
import { useUserProfile } from "./use";
|
||||
import { useContactBind, useUserProfile } from "./use";
|
||||
import { usePasskeyRegister } from "./use";
|
||||
import { message, Modal, notification } from "ant-design-vue";
|
||||
import { useSettingStore } from "/@/store/settings";
|
||||
@@ -160,6 +166,9 @@ const settingStore = useSettingStore();
|
||||
const userInfo: Ref = ref({});
|
||||
const passkeys = ref([]);
|
||||
const passkeySupported = ref(false);
|
||||
const contactCapability = ref({
|
||||
smsEnabled: false,
|
||||
});
|
||||
|
||||
const getUserInfo = async () => {
|
||||
userInfo.value = await api.getMineInfo();
|
||||
@@ -177,6 +186,7 @@ function doUpdate() {
|
||||
openEditProfileDialog({
|
||||
onUpdated: async () => {
|
||||
await getUserInfo();
|
||||
userStore.setUserInfo(userInfo.value);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -237,6 +247,23 @@ async function loadPasskeys() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContactCapability() {
|
||||
contactCapability.value = await api.GetContactCapability();
|
||||
}
|
||||
|
||||
const { openContactBindDialog } = useContactBind();
|
||||
async function openBindContact(type: "mobile" | "email") {
|
||||
await openContactBindDialog({
|
||||
type,
|
||||
userInfo: userInfo.value,
|
||||
contactCapability: contactCapability.value,
|
||||
onUpdated: async () => {
|
||||
await getUserInfo();
|
||||
userStore.setUserInfo(userInfo.value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function unbindPasskey(id: number) {
|
||||
Modal.confirm({
|
||||
title: "确认解绑吗?",
|
||||
@@ -366,6 +393,7 @@ const userAvatar = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await getUserInfo();
|
||||
await loadContactCapability();
|
||||
await loadOauthBounds();
|
||||
await loadOauthProviders();
|
||||
await loadPasskeys();
|
||||
@@ -613,6 +641,18 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-edit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
margin: -2px -6px -2px 0;
|
||||
padding: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user