614 lines
17 KiB
Vue
614 lines
17 KiB
Vue
<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>
|