2
0
Files
fe.wfc.user/src/views/_builtin/login/modules/register.vue

614 lines
17 KiB
Vue
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.
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { useFormRules } from '@/hooks/common/form';
import { useCaptcha } from '@/hooks/business/captcha';
import { useWindowSize } from '@vueuse/core';
import { registerTerms } from '@/views/_builtin/login/modules/terms';
import dayjs from 'dayjs';
import type { Rule } from 'ant-design-vue/es/form';
const { t } = useI18n();
const authStore = useAuthStore();
// 添加两个表单引用
const basicFormRef = ref();
const securityFormRef = ref();
defineOptions({
name: 'Register'
});
const { toggleLoginModule } = useRouterPush();
const { label, isCounting, loading, getCaptcha } = useCaptcha();
const { width } = useWindowSize();
const isMobile = computed(() => width.value <= 640);
// 当前步骤
const currentStep = ref(0);
// 是否同意协议
const agreeTerms = ref(false);
// 第一步表单数据
interface BasicFormModel {
username: string;
fullName: string;
birthDate: string;
gender: string;
phone: string;
address: string;
}
const basicModel = reactive<BasicFormModel>({
username: '',
fullName: '',
birthDate: '',
gender: '',
phone: '',
address: ''
});
// 第三表单数据
interface SecurityFormModel {
email: string;
code: string;
uuid: string;
password: string;
confirmPassword: string;
}
const securityModel = reactive<SecurityFormModel>({
email: '',
code: '',
uuid: '',
password: '',
confirmPassword: ''
});
// 第一步表单验证规则
const basicRules = computed<Record<string, Rule | Rule[]>>(() => {
const validateUsername = async (_rule: Rule, value: string) => {
// 空值检查
if (!value) {
return Promise.reject(t('page.login.register.usernameRequired'));
}
// 长度检查 (3-20字符)
if (value.length < 3 || value.length > 20) {
return Promise.reject(t('page.login.register.usernameLengthLimit'));
}
// 格式检查 (只允许字母、数字、下划线)
const usernamePattern = /^[a-zA-Z0-9_]+$/;
if (!usernamePattern.test(value)) {
return Promise.reject(t('page.login.register.usernameFormatError'));
}
// 首字符必须是字母
if (!/^[a-zA-Z]/.test(value)) {
return Promise.reject(t('page.login.register.usernameStartWithLetter'));
}
// 重复性检查
try {
const { exists } = await authStore.checkUserRepeat({ username: value, authType: 'u' });
if (exists) {
return Promise.reject(t('page.login.register.usernameExists'));
}
} catch (error) {
console.error('Username validation error:', error);
return Promise.reject(t('page.login.register.validationError'));
}
return Promise.resolve();
};
const validatePhone = async (_rule: Rule, value: string) => {
if (!value) {
return Promise.resolve();
}
// 去除所有空格后再验证
const trimmedValue = value.replace(/\s/g, '');
if (!trimmedValue) {
return Promise.resolve();
}
// 基本格式验证 - 至少3个字符
const phonePattern = /^.{3,}$/;
if (!phonePattern.test(trimmedValue)) {
return Promise.reject(t('page.login.register.phoneInvalid'));
}
// 手机号重复性验证 - 使用去除空格后的值
try {
const { exists } = await authStore.checkUserRepeat({ phonenumber: trimmedValue, authType: 'u' });
if (exists) {
return Promise.reject(t('page.login.register.phoneExists'));
}
return Promise.resolve();
} catch (error) {
console.error('Phone validation error:', error);
return Promise.reject(t('page.login.register.validationError'));
}
};
return {
username: [
{ required: true, message: t('page.login.register.usernameRequired'), trigger: 'change' },
{ validator: validateUsername, trigger: 'blur' }
],
phone: [
{ validator: validatePhone, trigger: 'blur' }
],
birthDate: [{ required: true, message: t('page.login.register.birthDateRequired') }],
fullName: []
};
});
// 第三步表单验证规则
const securityRules = computed<Record<string, Rule | Rule[]>>(() => {
const { createConfirmPwdRule } = useFormRules();
const validateEmail = async (_rule: Rule, value: string) => {
if (!value) {
return Promise.reject(t('page.login.register.emailRequired'));
}
const emailPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
if (!emailPattern.test(value)) {
return Promise.reject(t('page.login.register.emailInvalid'));
}
const { exists } = await authStore.checkUserRepeat({ email: value, authType: 'u' });
if (exists) {
return Promise.reject(t('page.login.register.emailExists'));
}
return Promise.resolve();
};
return {
email: [
{ required: true, message: t('page.login.register.emailRequired'), trigger: 'change' },
{ validator: validateEmail, trigger: 'blur' }
],
code: [{ required: true, message: t('page.login.register.codeRequired') }],
password: [
{ required: true, message: t('page.login.register.passwordRequired') },
{ min: 6, message: t('page.login.register.passwordLength') }
],
confirmPassword: createConfirmPwdRule(securityModel.password)
};
});
// 协议内容
const terms = computed(() => {
const locale = useI18n().locale.value;
return registerTerms[locale === 'zh-CN' ? 'zh-CN' : 'en-US'];
});
// 步骤控制函数
async function nextStep() {
try {
if (currentStep.value === 0) {
// 验证第一步表单
await basicFormRef.value?.validate();
}
if (currentStep.value === 1) {
if (!agreeTerms.value) {
window.$message?.error(t('page.login.register.agreeTermsFirst'));
return;
}
}
// 验证通过后增加步骤
currentStep.value += 1;
} catch (error) {
// 验证失败时不增加步骤
console.error('Validation failed:', error);
}
}
// 返回
function prevStep() {
currentStep.value -= 1;
}
async function handleCaptcha() {
const res = await getCaptcha(securityModel.email);
if (res) {
securityModel.uuid = res.data.uuid;
if (res.data?.text) {
securityModel.code = res.data.text;
}
}
}
// 注册按钮
async function handleSubmit() {
try {
await securityFormRef.value?.validate();
// 整合表单数据
const baseData = {
username: basicModel.username,
password: securityModel.password,
email: securityModel.email,
fullName: basicModel.fullName,
gender: basicModel.gender,
address: basicModel.address,
code: securityModel.code,
uuid: securityModel.uuid,
age: dayjs().diff(dayjs(basicModel.birthDate), 'year'),
sex: basicModel.gender,
authType: 'u'
};
// 创建最终的提交数据,只在有手机号时添加
const submitData = {
...baseData,
...(basicModel.phone ? { phonenumber: basicModel.phone } : {})
};
const success = await authStore.register(submitData);
if (success) {
window.$message?.success(t('page.login.register.registerSuccess'));
toggleLoginModule('pwd-login');
}
} catch (error) {
console.error('Form validation failed:', error);
}
}
// 处理手机号输入
const handlePhoneInput = (e: Event) => {
const input = e.target as HTMLInputElement;
let value = input.value;
// 去除所有空格
value = value.replace(/\s/g, '');
// 处理加号:只保留第一个加号,去除其他加号
if (value.includes('+')) {
const parts = value.split('+');
value = '+' + parts.slice(1).join('').replace(/\+/g, '');
}
// 去除非数字和加号以外的字符
value = value.replace(/[^\d+]/g, '');
// 如果只有加号,清空值
if (value === '+') {
value = '';
}
// 限制长度
if (value.length > 20) {
value = value.slice(0, 20);
}
// 格式化手机号
if (value.startsWith('+')) {
// 如果以+开头
if (value.startsWith('+86')) {
// 中国区号格式:+86 185 1234 5678
const restNumber = value.slice(3); // 去掉+86
if (restNumber) {
value = `+86 ${restNumber.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}`;
}
} else {
// 其他国际号码格式:+xx xxxx xxxx xxxx
const countryCode = value.match(/^\+\d{1,3}/)?.[0] || '';
const restNumber = value.slice(countryCode.length);
const formattedRest = restNumber.replace(/(\d{4})/g, '$1 ').trim();
value = countryCode ? `${countryCode} ${formattedRest}` : formattedRest;
}
} else if (value.startsWith('1') && value.length <= 11) {
// 如果是国内手机号以1开头且长度不超过11位185 1234 5678
value = value.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3');
} else {
// 其他情况可能是直接输入的国外号码xxxx xxxx xxxx
value = value.replace(/(\d{4})/g, '$1 ').trim();
}
basicModel.phone = value;
};
// 在script setup部分添加一个新的计算属性
const showSteps = computed(() => !isMobile.value);
</script>
<template>
<div class="register-container">
<ASteps
v-if="showSteps"
:current="currentStep"
size="small"
class="max-w-full mb-16px"
direction="horizontal"
:responsive="false"
>
<AStep :title="t('page.login.register.basicInfo')" />
<AStep :title="t('page.login.register.terms')" />
<AStep :title="t('page.login.register.security')" />
</ASteps>
<div v-else class="mobile-step-indicator mb-16px text-center">
{{ currentStep + 1 }}/3: {{
currentStep === 0
? t('page.login.register.basicInfo')
: currentStep === 1
? t('page.login.register.terms')
: t('page.login.register.security')
}}
</div>
<div class="step-content">
<!-- 第一步基本信息 -->
<div v-show="currentStep === 0">
<AForm
ref="basicFormRef"
:model="basicModel"
:rules="basicRules"
:label-wrap="true"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
>
<ARow :gutter="[8,2]">
<ACol :span="24" :lg="24">
<AFormItem name="username" :label="t('page.login.register.username')">
<AInput v-model:value="basicModel.username" />
</AFormItem>
</ACol>
<ACol :span="24" :lg="24">
<AFormItem name="fullName" :label="t('page.login.register.fullName')">
<AInput v-model:value="basicModel.fullName" />
</AFormItem>
</ACol>
<ACol :xs="12" :sm="12" :lg="24">
<AFormItem name="birthDate" :label="t('page.login.register.birthDate')">
<ADatePicker
v-model:value="basicModel.birthDate"
class="!w-full birth-date-picker"
:placeholder="t('page.login.register.birthDatePlaceholder')"
:disabled-date="(current: dayjs.Dayjs) => current && current.isAfter(dayjs())"
/>
</AFormItem>
</ACol>
<ACol :xs="12" :sm="12" :lg="24">
<AFormItem name="gender" :label="t('page.login.register.gender')">
<ASelect v-model:value="basicModel.gender">
<ASelectOption value="0">{{ t('page.login.register.male') }}</ASelectOption>
<ASelectOption value="1">{{ t('page.login.register.female') }}</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :lg="24" :span="24">
<AFormItem name="phone" :label="t('page.login.register.phone')">
<AInput
v-model:value="basicModel.phone"
:placeholder="t('page.login.common.phonePlaceholder')"
@input="handlePhoneInput"
:maxLength="20"
allow-clear
/>
</AFormItem>
</ACol>
<ACol :lg="24" :span="24">
<AFormItem name="address" :label="t('page.login.register.address')">
<ATextarea v-model:value="basicModel.address" :rows="2" />
</AFormItem>
</ACol>
<ACol :lg="24" :span="24">
<ASpace direction="vertical" size="small" class="w-full">
<AButton type="primary" block size="small" @click="nextStep">
{{ t('page.login.register.next') }}
</AButton>
<AButton block size="small" @click="toggleLoginModule('pwd-login')">
{{ t('page.login.common.back') }}
</AButton>
</ASpace>
</ACol>
</ARow>
</AForm>
</div>
<!-- 第二步:协议 -->
<div v-show="currentStep === 1">
<ATextarea
:value="terms"
:rows="12"
readonly
class="mb-16px"
size="small"
:style="{ fontSize: '14px', lineHeight: '1.6' }"
/>
<div class="mb-16px">
<ACheckbox
v-model:checked="agreeTerms"
class="terms-checkbox"
>
{{ t('page.login.register.agreeTerms') }}
</ACheckbox>
</div>
<ASpace direction="vertical" size="small" class="w-full">
<AButton type="primary" block size="small" :disabled="!agreeTerms" @click="nextStep">
{{ t('page.login.register.next') }}
</AButton>
<AButton block size="small" @click="prevStep">
{{ t('page.login.register.prev') }}
</AButton>
</ASpace>
</div>
<!-- 第三步:安全信息 -->
<div v-show="currentStep === 2">
<AForm
ref="securityFormRef"
:model="securityModel"
:rules="securityRules"
:label-wrap="true"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
class="compact-form"
>
<AFormItem name="email" :label="t('page.login.register.email')">
<AInput
v-model:value="securityModel.email"
:placeholder="t('page.login.common.emailPlaceholder')"
/>
</AFormItem>
<AFormItem name="code" :label="t('page.login.register.code')">
<div class="w-full flex-y-center gap-8px">
<AInput
v-model:value="securityModel.code"
:placeholder="t('page.login.common.codePlaceholder')"
/>
<AButton
size="small"
:disabled="isCounting"
:loading="loading"
@click="handleCaptcha()"
>
{{ label }}
</AButton>
</div>
</AFormItem>
<AFormItem name="password" :label="t('page.login.register.password')">
<AInputPassword
v-model:value="securityModel.password"
:placeholder="t('page.login.common.passwordPlaceholder')"
/>
</AFormItem>
<AFormItem name="confirmPassword" :label="t('page.login.register.confirmPassword')">
<AInputPassword
v-model:value="securityModel.confirmPassword"
:placeholder="t('page.login.common.confirmPasswordPlaceholder')"
/>
</AFormItem>
<AFormItem :wrapper-col="{ xs: { span: 24 }, sm: { span: 24 } }">
<ASpace direction="vertical" size="small" class="w-full">
<AButton type="primary" block size="small" @click="handleSubmit">
{{ t('common.confirm') }}
</AButton>
<AButton block size="small" @click="prevStep">
{{ t('page.login.register.prev') }}
</AButton>
</ASpace>
</AFormItem>
</AForm>
</div>
</div>
</div>
</template>
<style scoped>
.register-container {
width: 100%;
}
.step-content {
position: relative;
}
.step-form {
width: 100%;
}
@media (max-width: 640px) {
:deep(.ant-form) {
.ant-form-item {
margin-bottom: 4px !important;
}
.ant-form-item-label {
padding: 0 !important;
line-height: 28px !important;
> label {
font-size: 13px !important;
height: 28px !important;
padding-bottom: 0 !important;
}
}
.ant-input,
.ant-input-password,
.ant-select,
.ant-input-number,
.ant-btn {
font-size: 13px !important;
height: 28px !important;
line-height: 28px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.ant-select-selector {
height: 28px !important;
padding: 0 8px !important;
.ant-select-selection-item {
line-height: 26px !important;
}
}
.ant-input-number {
height: 32px !important;
input {
height: 30px !important;
padding: 0 8px !important;
}
}
}
.mobile-step-indicator {
font-size: 13px;
padding: 4px 8px;
margin-bottom: 8px;
text-align: center;
width: 100%;
border-radius: 4px;
}
:deep(.ant-space) {
gap: 4px !important;
}
}
:deep(.terms-checkbox) {
.ant-checkbox + span {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
}
:deep(.ant-input[readonly]) {
background-color: #f5f5f5;
cursor: default;
}
:deep(.ant-form-item-label) {
white-space: normal;
text-align: left;
> label {
height: auto !important;
padding-bottom: 4px;
}
}
.birth-date-picker {
margin-top: 2px !important;
}
.phone-input {
width: 100%;
}
</style>