514 lines
13 KiB
Vue
514 lines
13 KiB
Vue
|
|
<template>
|
||
|
|
<div class="register">
|
||
|
|
<a-row justify="center" align="middle" class="register-container">
|
||
|
|
<a-col :xs="22" :sm="16" :md="12" :lg="8" :xl="6">
|
||
|
|
<a-card class="register-card">
|
||
|
|
<template #header>
|
||
|
|
<div class="register-header">
|
||
|
|
<UserOutlined style="font-size: 32px; color: #67C23A" />
|
||
|
|
<h2>{{ $t('register.title') }}</h2>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<a-form
|
||
|
|
ref="registerFormRef"
|
||
|
|
:model="registerForm"
|
||
|
|
:rules="registerRules"
|
||
|
|
label-width="80px"
|
||
|
|
@submit.prevent="handleRegister"
|
||
|
|
>
|
||
|
|
<a-form-item :label="$t('profile.username')" prop="username">
|
||
|
|
<a-input
|
||
|
|
v-model="registerForm.username"
|
||
|
|
:placeholder="$t('register.usernamePlaceholder')"
|
||
|
|
prefix-icon="User"
|
||
|
|
clearable
|
||
|
|
@blur="checkUsername"
|
||
|
|
/>
|
||
|
|
<div v-if="usernameChecking" class="checking-text">
|
||
|
|
<LoadingOutlined class="is-loading" />
|
||
|
|
{{ $t('common.loading') }}
|
||
|
|
</div>
|
||
|
|
<div v-if="usernameExists" class="error-text">
|
||
|
|
<CloseCircleFilled />
|
||
|
|
{{ $t('register.usernameExists') }}
|
||
|
|
</div>
|
||
|
|
<div v-if="usernameAvailable" class="success-text">
|
||
|
|
<CheckCircleFilled />
|
||
|
|
{{ $t('register.usernameAvailable') }}
|
||
|
|
</div>
|
||
|
|
</a-form-item>
|
||
|
|
|
||
|
|
<a-form-item :label="$t('profile.email')" prop="email">
|
||
|
|
<a-input
|
||
|
|
v-model="registerForm.email"
|
||
|
|
:placeholder="$t('register.emailPlaceholder')"
|
||
|
|
prefix-icon="Message"
|
||
|
|
clearable
|
||
|
|
@blur="checkEmail"
|
||
|
|
/>
|
||
|
|
<div v-if="emailChecking" class="checking-text">
|
||
|
|
<LoadingOutlined class="is-loading" />
|
||
|
|
{{ $t('common.loading') }}
|
||
|
|
</div>
|
||
|
|
<div v-if="emailExists" class="error-text">
|
||
|
|
<CloseCircleFilled />
|
||
|
|
{{ $t('register.emailExists') }}
|
||
|
|
</div>
|
||
|
|
<div v-if="emailAvailable" class="success-text">
|
||
|
|
<CheckCircleFilled />
|
||
|
|
{{ $t('register.emailAvailable') }}
|
||
|
|
</div>
|
||
|
|
</a-form-item>
|
||
|
|
|
||
|
|
<a-form-item :label="$t('login.passwordPlaceholder').replace('请输入', '')" prop="password">
|
||
|
|
<a-input
|
||
|
|
v-model="registerForm.password"
|
||
|
|
type="password"
|
||
|
|
:placeholder="$t('register.passwordPlaceholder')"
|
||
|
|
prefix-icon="Lock"
|
||
|
|
show-password
|
||
|
|
clearable
|
||
|
|
@input="checkPasswordStrength"
|
||
|
|
/>
|
||
|
|
<div v-if="passwordStrength" class="password-strength">
|
||
|
|
<div class="strength-bar">
|
||
|
|
<div
|
||
|
|
class="strength-fill"
|
||
|
|
:class="strengthClass"
|
||
|
|
:style="{ width: strengthWidth }"
|
||
|
|
></div>
|
||
|
|
</div>
|
||
|
|
<span class="strength-text">{{ strengthText }}</span>
|
||
|
|
</div>
|
||
|
|
</a-form-item>
|
||
|
|
|
||
|
|
<a-form-item :label="$t('common.confirm')" prop="confirmPassword">
|
||
|
|
<a-input
|
||
|
|
v-model="registerForm.confirmPassword"
|
||
|
|
type="password"
|
||
|
|
:placeholder="$t('register.confirmPasswordPlaceholder')"
|
||
|
|
prefix-icon="Lock"
|
||
|
|
show-password
|
||
|
|
clearable
|
||
|
|
/>
|
||
|
|
</a-form-item>
|
||
|
|
|
||
|
|
<a-form-item>
|
||
|
|
<a-checkbox v-model:checked="agreeTerms">
|
||
|
|
{{ $t('register.agreement') }}
|
||
|
|
</a-checkbox>
|
||
|
|
</a-form-item>
|
||
|
|
|
||
|
|
<a-form-item>
|
||
|
|
<a-button
|
||
|
|
type="success"
|
||
|
|
size="large"
|
||
|
|
:loading="userStore.loading"
|
||
|
|
:disabled="!canRegister"
|
||
|
|
@click="handleRegister"
|
||
|
|
class="register-button"
|
||
|
|
>
|
||
|
|
{{ userStore.loading ? $t('register.registering') : $t('register.registerButton') }}
|
||
|
|
</a-button>
|
||
|
|
</a-form-item>
|
||
|
|
</a-form>
|
||
|
|
|
||
|
|
<div class="register-footer">
|
||
|
|
<p>{{ $t('register.haveAccount') }}<router-link to="/login" class="login-link">{{ $t('register.loginNow') }}</router-link></p>
|
||
|
|
</div>
|
||
|
|
</a-card>
|
||
|
|
</a-col>
|
||
|
|
</a-row>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup>
|
||
|
|
import { ref, reactive, computed } from 'vue'
|
||
|
|
import { useRouter } from 'vue-router'
|
||
|
|
import { useUserStore } from '@/stores/user'
|
||
|
|
import { checkUsernameExists, checkEmailExists } from '@/api/auth'
|
||
|
|
import { message } from 'ant-design-vue'
|
||
|
|
import { useI18n } from 'vue-i18n'
|
||
|
|
import {
|
||
|
|
UserOutlined,
|
||
|
|
LockOutlined,
|
||
|
|
LoadingOutlined,
|
||
|
|
CloseCircleFilled,
|
||
|
|
CheckCircleFilled,
|
||
|
|
MailOutlined,
|
||
|
|
PhoneOutlined,
|
||
|
|
CalendarOutlined,
|
||
|
|
EnvironmentOutlined
|
||
|
|
} from '@ant-design/icons-vue'
|
||
|
|
|
||
|
|
const { t } = useI18n()
|
||
|
|
const router = useRouter()
|
||
|
|
const userStore = useUserStore()
|
||
|
|
|
||
|
|
const registerFormRef = ref()
|
||
|
|
const agreeTerms = ref(false)
|
||
|
|
|
||
|
|
// 用户名检查状态
|
||
|
|
const usernameChecking = ref(false)
|
||
|
|
const usernameExists = ref(false)
|
||
|
|
const usernameAvailable = ref(false)
|
||
|
|
|
||
|
|
// 邮箱检查状态
|
||
|
|
const emailChecking = ref(false)
|
||
|
|
const emailExists = ref(false)
|
||
|
|
const emailAvailable = ref(false)
|
||
|
|
|
||
|
|
// 密码强度
|
||
|
|
const passwordStrength = ref(false)
|
||
|
|
const strengthLevel = ref(0)
|
||
|
|
|
||
|
|
const registerForm = reactive({
|
||
|
|
username: '',
|
||
|
|
email: '',
|
||
|
|
password: '',
|
||
|
|
confirmPassword: ''
|
||
|
|
})
|
||
|
|
|
||
|
|
const registerRules = {
|
||
|
|
username: [
|
||
|
|
{ required: true, message: t('register.usernameRequired'), trigger: 'blur' },
|
||
|
|
{ min: 3, max: 20, message: t('register.usernameLength'), trigger: 'blur' },
|
||
|
|
{ pattern: /^[a-zA-Z0-9_]+$/, message: t('register.usernameFormat'), trigger: 'blur' }
|
||
|
|
],
|
||
|
|
email: [
|
||
|
|
{ required: true, message: t('register.emailRequired'), trigger: 'blur' },
|
||
|
|
{ type: 'email', message: t('register.emailFormat'), trigger: 'blur' }
|
||
|
|
],
|
||
|
|
password: [
|
||
|
|
{ required: true, message: t('register.passwordRequired'), trigger: 'blur' },
|
||
|
|
{ min: 6, max: 20, message: t('register.passwordLength'), trigger: 'blur' }
|
||
|
|
],
|
||
|
|
confirmPassword: [
|
||
|
|
{ required: true, message: t('register.confirmPasswordRequired'), trigger: 'blur' },
|
||
|
|
{
|
||
|
|
validator: (rule, value, callback) => {
|
||
|
|
if (value !== registerForm.password) {
|
||
|
|
callback(new Error(t('register.passwordMismatch')))
|
||
|
|
} else {
|
||
|
|
callback()
|
||
|
|
}
|
||
|
|
},
|
||
|
|
trigger: 'blur'
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查用户名是否存在
|
||
|
|
const checkUsername = async () => {
|
||
|
|
if (!registerForm.username || registerForm.username.length < 3) {
|
||
|
|
resetUsernameCheck()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
usernameChecking.value = true
|
||
|
|
usernameExists.value = false
|
||
|
|
usernameAvailable.value = false
|
||
|
|
|
||
|
|
const response = await checkUsernameExists(registerForm.username)
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
usernameExists.value = response.data.exists
|
||
|
|
usernameAvailable.value = !response.data.exists
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('CheckOutlined username error:', error)
|
||
|
|
} finally {
|
||
|
|
usernameChecking.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查邮箱是否存在
|
||
|
|
const checkEmail = async () => {
|
||
|
|
if (!registerForm.email || !isValidEmail(registerForm.email)) {
|
||
|
|
resetEmailCheck()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
emailChecking.value = true
|
||
|
|
emailExists.value = false
|
||
|
|
emailAvailable.value = false
|
||
|
|
|
||
|
|
const response = await checkEmailExists(registerForm.email)
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
emailExists.value = response.data.exists
|
||
|
|
emailAvailable.value = !response.data.exists
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('CheckOutlined email error:', error)
|
||
|
|
} finally {
|
||
|
|
emailChecking.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查密码强度
|
||
|
|
const checkPasswordStrength = () => {
|
||
|
|
const password = registerForm.password
|
||
|
|
if (!password) {
|
||
|
|
passwordStrength.value = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
passwordStrength.value = true
|
||
|
|
|
||
|
|
let score = 0
|
||
|
|
if (password.length >= 6) score++
|
||
|
|
if (password.length >= 8) score++
|
||
|
|
if (/[a-z]/.test(password)) score++
|
||
|
|
if (/[A-Z]/.test(password)) score++
|
||
|
|
if (/[0-9]/.test(password)) score++
|
||
|
|
if (/[^A-Za-z0-9]/.test(password)) score++
|
||
|
|
|
||
|
|
strengthLevel.value = Math.min(score, 4)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 重置用户名检查状态
|
||
|
|
const resetUsernameCheck = () => {
|
||
|
|
usernameChecking.value = false
|
||
|
|
usernameExists.value = false
|
||
|
|
usernameAvailable.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
// 重置邮箱检查状态
|
||
|
|
const resetEmailCheck = () => {
|
||
|
|
emailChecking.value = false
|
||
|
|
emailExists.value = false
|
||
|
|
emailAvailable.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
// 验证邮箱格式
|
||
|
|
const isValidEmail = (email) => {
|
||
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
|
|
return emailRegex.test(email)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 计算属性
|
||
|
|
const strengthClass = computed(() => {
|
||
|
|
const classes = ['weak', 'fair', 'good', 'strong']
|
||
|
|
return classes[strengthLevel.value - 1] || 'weak'
|
||
|
|
})
|
||
|
|
|
||
|
|
const strengthWidth = computed(() => {
|
||
|
|
return `${(strengthLevel.value / 4) * 100}%`
|
||
|
|
})
|
||
|
|
|
||
|
|
const strengthText = computed(() => {
|
||
|
|
const texts = [t('common.weak'), t('common.fair'), t('common.good'), t('common.strong')]
|
||
|
|
return texts[strengthLevel.value - 1] || t('common.weak')
|
||
|
|
})
|
||
|
|
|
||
|
|
const canRegister = computed(() => {
|
||
|
|
return agreeTerms.value &&
|
||
|
|
usernameAvailable.value &&
|
||
|
|
emailAvailable.value &&
|
||
|
|
registerForm.password &&
|
||
|
|
registerForm.confirmPassword &&
|
||
|
|
registerForm.password === registerForm.confirmPassword
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleRegister = async () => {
|
||
|
|
if (!registerFormRef.value) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
const valid = await registerFormRef.value.validate()
|
||
|
|
if (!valid) return
|
||
|
|
|
||
|
|
if (!agreeTerms.value) {
|
||
|
|
message.warning(t('common.pleaseAgreeTerms'))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await userStore.registerUser(registerForm)
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
message.success(result.message || t('common.success'))
|
||
|
|
router.push('/login')
|
||
|
|
} else {
|
||
|
|
message.error(result.message || t('common.updateFailed'))
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Register error:', error)
|
||
|
|
message.error(t('common.registerFailed'))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.register {
|
||
|
|
min-height: calc(100vh - 120px);
|
||
|
|
background: linear-gradient(135deg, var(--secondary-500) 0%, var(--secondary-600) 100%);
|
||
|
|
padding: var(--space-10) 0;
|
||
|
|
position: relative;
|
||
|
|
overflow-x: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 页面特殊效果 */
|
||
|
|
.register::before {
|
||
|
|
content: '';
|
||
|
|
position: absolute;
|
||
|
|
top: 0;
|
||
|
|
left: 0;
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
background:
|
||
|
|
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
|
||
|
|
radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.05) 0%, transparent 50%);
|
||
|
|
animation: registerFloat 4s ease-in-out infinite alternate;
|
||
|
|
pointer-events: none;
|
||
|
|
z-index: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes registerFloat {
|
||
|
|
0% { transform: translateY(0px) rotate(0deg); }
|
||
|
|
100% { transform: translateY(-10px) rotate(1deg); }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 内容层级 */
|
||
|
|
.register > * {
|
||
|
|
position: relative;
|
||
|
|
z-index: 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-container {
|
||
|
|
min-height: calc(100vh - 200px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-card {
|
||
|
|
box-shadow: var(--shadow-lg);
|
||
|
|
border-radius: var(--radius-lg);
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
background: var(--bg-surface);
|
||
|
|
border: 1px solid var(--border-default);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-header {
|
||
|
|
text-align: center;
|
||
|
|
margin-bottom: var(--space-5);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-header h2 {
|
||
|
|
margin: var(--space-3) 0 0 0;
|
||
|
|
color: var(--text-primary);
|
||
|
|
font-weight: var(--font-semibold);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-button {
|
||
|
|
width: 100%;
|
||
|
|
height: 45px;
|
||
|
|
font-size: var(--text-base);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-footer {
|
||
|
|
text-align: center;
|
||
|
|
margin-top: var(--space-5);
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-footer p {
|
||
|
|
margin: 0;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-link {
|
||
|
|
color: var(--success-500);
|
||
|
|
text-decoration: none;
|
||
|
|
font-weight: var(--font-medium);
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-link:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.terms-link {
|
||
|
|
color: var(--primary-500);
|
||
|
|
text-decoration: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.terms-link:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.checking-text, .error-text, .success-text {
|
||
|
|
font-size: var(--text-xs);
|
||
|
|
margin-top: var(--space-1);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: var(--space-1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.checking-text {
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.error-text {
|
||
|
|
color: var(--error-400);
|
||
|
|
}
|
||
|
|
|
||
|
|
.success-text {
|
||
|
|
color: var(--success-500);
|
||
|
|
}
|
||
|
|
|
||
|
|
.password-strength {
|
||
|
|
margin-top: var(--space-2);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-bar {
|
||
|
|
height: 4px;
|
||
|
|
background-color: var(--bg-elevated);
|
||
|
|
border-radius: 2px;
|
||
|
|
overflow: hidden;
|
||
|
|
margin-bottom: var(--space-1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-fill {
|
||
|
|
height: 100%;
|
||
|
|
transition: all var(--duration-slow) var(--ease-default);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-fill.weak {
|
||
|
|
background-color: var(--error-400);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-fill.fair {
|
||
|
|
background-color: var(--warning-500);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-fill.good {
|
||
|
|
background-color: var(--primary-500);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-fill.strong {
|
||
|
|
background-color: var(--success-500);
|
||
|
|
}
|
||
|
|
|
||
|
|
.strength-text {
|
||
|
|
font-size: var(--text-xs);
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 768px) {
|
||
|
|
.register {
|
||
|
|
padding: var(--space-5) 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.register-container {
|
||
|
|
min-height: calc(100vh - 160px);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|