425 lines
11 KiB
Vue
425 lines
11 KiB
Vue
<template>
|
||
<div class="signup-container">
|
||
<div class="signup-card">
|
||
<div class="signup-header">
|
||
<div class="logo">
|
||
<img src="/logo.svg" :alt="appName" width="48" height="48" />
|
||
</div>
|
||
<h1 class="title">{{ appName }}</h1>
|
||
</div>
|
||
|
||
<n-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="rules"
|
||
size="medium"
|
||
:show-label="false"
|
||
@keyup.enter="handleSignup"
|
||
>
|
||
<n-form-item path="username">
|
||
<n-input
|
||
v-model:value="formData.username"
|
||
:placeholder="t('Username')"
|
||
:input-props="{ autocomplete: 'username' }"
|
||
>
|
||
<template #prefix>
|
||
<Icon icon="tabler:user" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<n-form-item path="password">
|
||
<n-input
|
||
v-model:value="formData.password"
|
||
type="password"
|
||
:placeholder="t('Password')"
|
||
:input-props="{ autocomplete: 'new-password' }"
|
||
show-password-on="click"
|
||
>
|
||
<template #prefix>
|
||
<Icon icon="tabler:lock" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<n-form-item path="confirmPassword">
|
||
<n-input
|
||
v-model:value="formData.confirmPassword"
|
||
type="password"
|
||
:placeholder="t('Confirm Password')"
|
||
:input-props="{ autocomplete: 'new-password' }"
|
||
show-password-on="click"
|
||
>
|
||
<template #prefix>
|
||
<Icon icon="tabler:lock" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<n-form-item path="email">
|
||
<n-input
|
||
v-model:value="formData.email"
|
||
:placeholder="isEnglish ? t('Email') : t('Email (Optional)')"
|
||
:input-props="{ autocomplete: 'email', type: 'email' }"
|
||
>
|
||
<template #prefix>
|
||
<Icon icon="tabler:mail" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<n-form-item v-if="!isEnglish" path="phoneNumber">
|
||
<n-input
|
||
v-model:value="formData.phoneNumber"
|
||
:placeholder="t('Mobile')"
|
||
:input-props="{ autocomplete: 'tel' }"
|
||
>
|
||
<template #prefix>
|
||
<Icon icon="tabler:phone" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<n-form-item>
|
||
<n-button
|
||
type="primary"
|
||
size="medium"
|
||
block
|
||
:loading="loading"
|
||
@click="handleSignup"
|
||
class="brand-button"
|
||
>
|
||
{{ t('Sign up') }}
|
||
</n-button>
|
||
</n-form-item>
|
||
</n-form>
|
||
|
||
<div class="signup-footer">
|
||
<n-text depth="3">
|
||
{{ t('Already have an account?') }}
|
||
<router-link to="/login" class="login-link">
|
||
{{ t('Login') }}
|
||
</router-link>
|
||
</n-text>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
|
||
import { Icon } from '@iconify/vue'
|
||
import { useAuthStore } from '../../shared/stores/auth'
|
||
import { t, getCurrentLocale } from '../../shared/i18n'
|
||
|
||
const router = useRouter()
|
||
const message = useMessage()
|
||
const authStore = useAuthStore()
|
||
|
||
const formRef = ref()
|
||
const loading = ref(false)
|
||
|
||
const formData = reactive({
|
||
username: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
email: '',
|
||
phoneNumber: ''
|
||
})
|
||
|
||
const validatePasswordMatch = (_rule: any, value: string) => {
|
||
if (value !== formData.password) {
|
||
return new Error(t('Passwords do not match'))
|
||
}
|
||
return true
|
||
}
|
||
|
||
const isEnglish = computed(() => getCurrentLocale() === 'en-US')
|
||
|
||
const rules = computed(() => {
|
||
const rules: any = {
|
||
username: [
|
||
{ required: true, message: t('Please enter username'), trigger: 'blur' },
|
||
{ min: 3, message: t('Username must be at least 3 characters'), trigger: 'blur' }
|
||
],
|
||
password: [
|
||
{ required: true, message: t('Please enter password'), trigger: 'blur' },
|
||
{ min: 6, message: t('Password must be at least 6 characters'), trigger: 'blur' }
|
||
],
|
||
confirmPassword: [
|
||
{ required: true, message: t('Please confirm password'), trigger: 'blur' },
|
||
{ validator: validatePasswordMatch, trigger: 'blur' }
|
||
]
|
||
}
|
||
|
||
// 英文版:email必填,手机号可选
|
||
if (isEnglish.value) {
|
||
rules.email = [
|
||
{ required: true, message: t('Please enter email'), trigger: 'blur' },
|
||
{
|
||
validator: (_rule: any, value: string) => {
|
||
// required规则已处理空值,这里只验证格式
|
||
if (!value) return true
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
if (!emailRegex.test(value)) {
|
||
return new Error(t('Please enter a valid email address'))
|
||
}
|
||
return true
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
rules.phoneNumber = [
|
||
{
|
||
validator: (_rule: any, value: string) => {
|
||
if (!value) return true
|
||
const phoneRegex = /^1[3-9]\d{9}$/
|
||
if (!phoneRegex.test(value)) {
|
||
return new Error(t('Please enter a valid phone number'))
|
||
}
|
||
return true
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
} else {
|
||
// 中文版:email可选,手机号必填
|
||
rules.email = [
|
||
{
|
||
validator: (_rule: any, value: string) => {
|
||
if (!value) return true
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
if (!emailRegex.test(value)) {
|
||
return new Error(t('Please enter a valid email address'))
|
||
}
|
||
return true
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
rules.phoneNumber = [
|
||
{ required: true, message: t('Please enter phone number'), trigger: 'blur' },
|
||
{
|
||
pattern: /^1[3-9]\d{9}$/,
|
||
message: t('Please enter a valid phone number'),
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
}
|
||
|
||
return rules
|
||
})
|
||
|
||
const getReferrerIfAny = () => {
|
||
try {
|
||
return document.referrer || ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
const handleSignup = async () => {
|
||
try {
|
||
await formRef.value?.validate()
|
||
loading.value = true
|
||
|
||
const response = await fetch('/api/action/jcloud.api.account.signup_with_username', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json'
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
username: formData.username,
|
||
password: formData.password,
|
||
email: formData.email || null,
|
||
phone_number: isEnglish.value ? (formData.phoneNumber || null) : formData.phoneNumber || null,
|
||
referrer: getReferrerIfAny(),
|
||
product: router.currentRoute.value.query.product || null
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}))
|
||
const errorMessage = errorData?.messages?.length
|
||
? errorData.messages.join('\n')
|
||
: (errorData?.message || errorData?.exc || '注册失败,请检查您的信息')
|
||
throw new Error(errorMessage)
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
if (data && data.success === false) {
|
||
throw new Error(data.message || '注册失败,请检查您的信息')
|
||
}
|
||
|
||
localStorage.setItem('login_email', formData.username || formData.email)
|
||
|
||
if (data && data.dashboard_route) {
|
||
window.location.href = data.dashboard_route
|
||
} else {
|
||
window.location.href = '/'
|
||
}
|
||
} catch (error: any) {
|
||
console.error('注册异常:', error)
|
||
message.error(error.message || t('Sign up failed, please try again'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
|
||
|
||
onMounted(async () => {
|
||
// 初始化认证状态
|
||
await authStore.initAuth()
|
||
|
||
// 如果已经登录,直接跳转
|
||
if (authStore.isLoggedIn) {
|
||
router.push('/')
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.signup-container {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f5f5f5;
|
||
padding: 20px;
|
||
}
|
||
|
||
.signup-card {
|
||
width: 100%;
|
||
max-width: 480px;
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||
max-height: calc(100vh - 40px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.signup-header {
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
:deep(.n-form-item) {
|
||
margin-bottom: 6px !important;
|
||
}
|
||
|
||
:deep(.n-form-item:last-child) {
|
||
margin-bottom: 0 !important;
|
||
}
|
||
|
||
/* 只在有错误时才显示反馈区域,减少默认间距 */
|
||
:deep(.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper) {
|
||
min-height: 0 !important;
|
||
margin-top: 0 !important;
|
||
padding-top: 0 !important;
|
||
}
|
||
|
||
:deep(.n-form-item--error .n-form-item__feedback-wrapper) {
|
||
margin-top: 4px !important;
|
||
min-height: auto !important;
|
||
}
|
||
|
||
.logo {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.logo img {
|
||
width: 40px;
|
||
height: 40px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
margin: 0;
|
||
}
|
||
|
||
.signup-footer {
|
||
text-align: center;
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
}
|
||
|
||
.login-link {
|
||
color: #1fc76f;
|
||
text-decoration: none;
|
||
font-weight: 500;
|
||
margin-left: 4px;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.login-link:hover {
|
||
color: #1fc76f;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
:deep(.brand-button) {
|
||
background: #e6f8f0 !important;
|
||
border: 1px solid #1fc76f !important;
|
||
color: #0d684b !important;
|
||
}
|
||
|
||
:deep(.brand-button .n-button__border),
|
||
:deep(.brand-button .n-button__state-border) {
|
||
border: none !important;
|
||
border-color: transparent !important;
|
||
}
|
||
|
||
:deep(.brand-button:hover) {
|
||
background: #dcfce7 !important;
|
||
border-color: #1fc76f !important;
|
||
color: #166534 !important;
|
||
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
|
||
}
|
||
|
||
:deep(.brand-button:hover .n-button__border),
|
||
:deep(.brand-button:hover .n-button__state-border) {
|
||
border: none !important;
|
||
border-color: transparent !important;
|
||
}
|
||
|
||
:deep(.brand-button:active) {
|
||
background: #1fc76f !important;
|
||
border-color: #1fc76f !important;
|
||
color: white !important;
|
||
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2) !important;
|
||
}
|
||
|
||
:deep(.brand-button:active .n-button__border),
|
||
:deep(.brand-button:active .n-button__state-border) {
|
||
border: none !important;
|
||
border-color: transparent !important;
|
||
}
|
||
|
||
:deep(.brand-button:focus) {
|
||
background: #e6f8f0 !important;
|
||
border-color: #1fc76f !important;
|
||
color: #0d684b !important;
|
||
}
|
||
|
||
:deep(.brand-button:focus .n-button__border),
|
||
:deep(.brand-button:focus .n-button__state-border) {
|
||
border: none !important;
|
||
border-color: transparent !important;
|
||
}
|
||
</style>
|
||
|