All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m11s
登录仅保留账号与微信;注册先选渠道,代理码仅 URL 携带时自动绑定。 Co-authored-by: Cursor <cursoragent@cursor.com>
1227 lines
35 KiB
Vue
1227 lines
35 KiB
Vue
<template>
|
||
<div
|
||
:class="[
|
||
'login-container',
|
||
{
|
||
'login-container--light': isLightMode,
|
||
},
|
||
]"
|
||
>
|
||
<div class="login-split login-split--left" aria-hidden="true" />
|
||
<div class="login-split login-split--right" aria-hidden="true" />
|
||
|
||
<div class="login-pane login-pane--left">
|
||
<aside class="login-hero-panel">
|
||
<LoginHero :light="isLightMode" portal="customer" />
|
||
</aside>
|
||
<div class="login-characters-panel">
|
||
<LoginCharacters v-if="showCharacters" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="login-pane login-pane--right">
|
||
<div
|
||
:class="[
|
||
'login-form',
|
||
'login-form--ready',
|
||
{ 'login-form--register': step === 'web' },
|
||
]"
|
||
>
|
||
<div class="login-form-toolbar">
|
||
<LoginLangToggle />
|
||
<a-tooltip :content="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')">
|
||
<a-button
|
||
shape="circle"
|
||
type="text"
|
||
class="theme-toggle-btn"
|
||
:aria-label="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')"
|
||
@click="toggleAppearance"
|
||
>
|
||
<template #icon>
|
||
<icon-sun-fill v-if="!isLightMode" />
|
||
<icon-moon-fill v-else />
|
||
</template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
</div>
|
||
|
||
<div class="login-content">
|
||
<div class="nova-register">
|
||
<a
|
||
class="nova-register__header"
|
||
href="/"
|
||
:title="t('marketing.register.brandHint')"
|
||
@click.prevent="goOfficialSite"
|
||
>
|
||
<LoginLockLogo />
|
||
<p class="nova-register__brand-hint">{{ brandHintText }}</p>
|
||
</a>
|
||
|
||
<!-- 步骤 1:选择注册方式 -->
|
||
<template v-if="step === 'choose'">
|
||
<div class="nova-register__choose">
|
||
<button type="button" class="nova-register__choose-card" @click="selectWebRegister">
|
||
<span class="nova-register__choose-icon nova-register__choose-icon--web">
|
||
<icon-computer :size="22" />
|
||
</span>
|
||
<span class="nova-register__choose-title">{{ t('marketing.register.choose.web') }}</span>
|
||
<span class="nova-register__choose-desc">{{ t('marketing.register.choose.webDesc') }}</span>
|
||
</button>
|
||
<button type="button" class="nova-register__choose-card" @click="selectWechatRegister">
|
||
<span class="nova-register__choose-icon nova-register__choose-icon--wechat">
|
||
<icon-wechat :size="22" />
|
||
</span>
|
||
<span class="nova-register__choose-title">{{ t('marketing.register.choose.wechat') }}</span>
|
||
<span class="nova-register__choose-desc">{{ t('marketing.register.choose.wechatDesc') }}</span>
|
||
</button>
|
||
</div>
|
||
<p class="nova-register__footer">
|
||
{{ t('marketing.register.hasAccount') }}
|
||
<a-link @click="goLogin">{{ t('marketing.register.goLogin') }}</a-link>
|
||
</p>
|
||
</template>
|
||
|
||
<!-- 步骤 2a:网页注册 -->
|
||
<template v-else-if="step === 'web'">
|
||
<button type="button" class="nova-register__back" @click="backToChoose">
|
||
{{ t('marketing.register.backToChoose') }}
|
||
</button>
|
||
|
||
<a-alert
|
||
v-if="agentValidated === false && formState.agentCodeInput.trim()"
|
||
type="warning"
|
||
:show-icon="true"
|
||
class="nova-register__alert"
|
||
>
|
||
{{ t('marketing.register.agentInvalid') }}
|
||
</a-alert>
|
||
<a-alert
|
||
v-if="effectiveAgentCode && inviteCode"
|
||
type="info"
|
||
:show-icon="true"
|
||
class="nova-register__alert"
|
||
>
|
||
{{ t('marketing.register.agentPriorityHint') }}
|
||
</a-alert>
|
||
|
||
<form class="nova-register__form" autocomplete="off" @submit.prevent="handleSubmit">
|
||
<div class="nova-register__row">
|
||
<div class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.customerName') }}</div>
|
||
<a-input
|
||
v-model="formState.customerName"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.customerNamePlaceholder')"
|
||
@press-enter="focusNext('phone')"
|
||
/>
|
||
<div v-if="fieldErrors.customerName" class="nova-register__field-error">
|
||
{{ fieldErrors.customerName }}
|
||
</div>
|
||
</div>
|
||
<div class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.phone') }}</div>
|
||
<a-input
|
||
ref="phoneInputRef"
|
||
v-model="formState.phone"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.phonePlaceholder')"
|
||
autocomplete="tel"
|
||
@press-enter="focusNext('email')"
|
||
/>
|
||
<div v-if="fieldErrors.phone" class="nova-register__field-error">
|
||
{{ fieldErrors.phone }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.email') }}</div>
|
||
<a-input
|
||
ref="emailInputRef"
|
||
v-model="formState.email"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.emailPlaceholder')"
|
||
autocomplete="email"
|
||
@press-enter="focusNext('password')"
|
||
/>
|
||
<div v-if="fieldErrors.email" class="nova-register__field-error">
|
||
{{ fieldErrors.email }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nova-register__row">
|
||
<div class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.password') }}</div>
|
||
<a-input-password
|
||
ref="passwordInputRef"
|
||
v-model="formState.password"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.passwordPlaceholder')"
|
||
autocomplete="new-password"
|
||
@press-enter="focusNext('confirmPassword')"
|
||
/>
|
||
<div v-if="fieldErrors.password" class="nova-register__field-error">
|
||
{{ fieldErrors.password }}
|
||
</div>
|
||
</div>
|
||
<div class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.confirmPassword') }}</div>
|
||
<a-input-password
|
||
ref="confirmPasswordInputRef"
|
||
v-model="formState.confirmPassword"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.confirmPasswordPlaceholder')"
|
||
autocomplete="new-password"
|
||
@press-enter="handleSubmit"
|
||
/>
|
||
<div v-if="fieldErrors.confirmPassword" class="nova-register__field-error">
|
||
{{ fieldErrors.confirmPassword }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!agentLocked" class="nova-register__input-group">
|
||
<div class="nova-register__field-label">{{ t('marketing.register.agentField') }}</div>
|
||
<a-input
|
||
v-model="formState.agentCodeInput"
|
||
size="large"
|
||
allow-clear
|
||
class="nova-register__arco-input"
|
||
:placeholder="t('marketing.register.agentPlaceholder')"
|
||
@blur="onAgentInputBlur"
|
||
@clear="onAgentInputClear"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="submitError" class="nova-register__error-box">{{ submitError }}</div>
|
||
|
||
<div
|
||
v-if="agentLoading || agentBanner || referrerBanner"
|
||
class="nova-register__invite"
|
||
:class="inviteBannerClass"
|
||
>
|
||
<template v-if="agentLoading">
|
||
<span class="nova-register__invite-dot" />
|
||
{{ t('marketing.register.agentValidating') }}
|
||
</template>
|
||
<template v-else-if="agentBanner">
|
||
<span class="nova-register__invite-label">{{ t('marketing.register.agentBadge') }}</span>
|
||
<span class="nova-register__invite-text">
|
||
{{
|
||
t('marketing.register.agentHint', {
|
||
name: agentNameMasked || t('marketing.register.agentFallbackName'),
|
||
code: effectiveAgentCode,
|
||
})
|
||
}}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="referrerBanner">
|
||
<span class="nova-register__invite-label">{{ t('marketing.register.referrerBadge') }}</span>
|
||
<span class="nova-register__invite-text">{{ referrerBanner.text }}</span>
|
||
</template>
|
||
</div>
|
||
|
||
<button type="submit" class="nova-register__submit" :disabled="submitting">
|
||
{{ submitting ? t('marketing.register.submitting') : t('marketing.register.submit') }}
|
||
</button>
|
||
</form>
|
||
|
||
<p class="nova-register__footer">
|
||
{{ t('marketing.register.hasAccount') }}
|
||
<a-link @click="goLogin">{{ t('marketing.register.goLogin') }}</a-link>
|
||
</p>
|
||
</template>
|
||
|
||
<!-- 步骤 2b:微信注册 -->
|
||
<template v-else-if="step === 'wechat'">
|
||
<WechatLogin intent="register" @switch-pwd="backToChoose" />
|
||
<p class="nova-register__footer">
|
||
{{ t('marketing.register.hasAccount') }}
|
||
<a-link @click="goLogin">{{ t('marketing.register.goLogin') }}</a-link>
|
||
</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import {
|
||
IconComputer,
|
||
IconMoonFill,
|
||
IconSunFill,
|
||
IconWechat,
|
||
} from '@arco-design/web-vue/es/icon'
|
||
import { CustomerService } from '@/api/customer'
|
||
import { validateInviteCode } from '@/api/marketing/portal'
|
||
import { validateAgentCode } from '@/api/agent'
|
||
import { isLetterAndDigitPassword } from '@/utils/customerPassword'
|
||
import {
|
||
clearStashedAdvisorInvite,
|
||
resolveAdvisorInvite,
|
||
stashAdvisorInvite,
|
||
} from '@/utils/advisorInviteStorage'
|
||
import {
|
||
clearStashedAgentInvite,
|
||
stashAgentInvite,
|
||
} from '@/utils/agentInviteStorage'
|
||
import { stashInviteRef } from '@/utils/inviteRefStorage'
|
||
import { isTimeoutError, withTimeout } from '@/utils/withTimeout'
|
||
import { isWeChatBrowser, preferH5Portal } from '@/utils/device'
|
||
import { isAdminH5Path } from '@/utils/login-portal'
|
||
import LoginHero from '@/views/login/components/LoginHero.vue'
|
||
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue'
|
||
import LoginLockLogo from '@/views/login/components/LoginLockLogo.vue'
|
||
|
||
const LoginCharacters = defineAsyncComponent(
|
||
() => import('@/views/login/components/LoginCharacters.vue'),
|
||
)
|
||
const WechatLogin = defineAsyncComponent(
|
||
() => import('@/views/login/components/wechat-login.vue'),
|
||
)
|
||
|
||
type RegisterStep = 'choose' | 'web' | 'wechat'
|
||
|
||
defineOptions({ name: 'CustomerRegister' })
|
||
|
||
const REFERRER_TIMEOUT_MS = 15000
|
||
const AGENT_TIMEOUT_MS = 15000
|
||
const LOGIN_APPEARANCE_KEY = 'login-appearance'
|
||
const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll'
|
||
|
||
const { t } = useI18n()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const showCharacters = ref(false)
|
||
const step = ref<RegisterStep>(parseStepFromQuery(route.query.mode))
|
||
const submitting = ref(false)
|
||
const submitError = ref('')
|
||
const inviteCode = ref('')
|
||
const advisorCode = ref('')
|
||
const agentLocked = ref(false)
|
||
|
||
function parseStepFromQuery(mode: unknown): RegisterStep {
|
||
const value = typeof mode === 'string' ? mode.trim().toLowerCase() : ''
|
||
if (value === 'web' || value === 'form') return 'web'
|
||
if (value === 'wechat' || value === 'wx') return 'wechat'
|
||
return 'choose'
|
||
}
|
||
|
||
const brandHintText = computed(() => {
|
||
if (step.value === 'web') return t('marketing.register.brandHint.web')
|
||
if (step.value === 'wechat') return t('marketing.register.brandHint.wechat')
|
||
return t('marketing.register.brandHint')
|
||
})
|
||
|
||
function attributionQuery(): Record<string, string> {
|
||
const query: Record<string, string> = {}
|
||
for (const key of ['agent', 'agentCode', 'ref', 'invite', 'advisor', 'redirect'] as const) {
|
||
const raw = route.query[key]
|
||
const value = typeof raw === 'string' ? raw.trim() : ''
|
||
if (value) query[key] = value
|
||
}
|
||
return query
|
||
}
|
||
|
||
function syncStepQuery(next: RegisterStep) {
|
||
const query = attributionQuery()
|
||
if (next === 'web' || next === 'wechat') {
|
||
query.mode = next
|
||
}
|
||
void router.replace({ path: '/register', query })
|
||
}
|
||
|
||
function backToChoose() {
|
||
step.value = 'choose'
|
||
syncStepQuery('choose')
|
||
}
|
||
|
||
function selectWebRegister() {
|
||
step.value = 'web'
|
||
syncStepQuery('web')
|
||
}
|
||
|
||
function selectWechatRegister() {
|
||
// 手机 / 微信内走 H5 微信注册,PC 同壳扫码
|
||
if (preferH5Portal() || isWeChatBrowser()) {
|
||
void router.push({ path: '/wechat/register', query: attributionQuery() })
|
||
return
|
||
}
|
||
step.value = 'wechat'
|
||
syncStepQuery('wechat')
|
||
}
|
||
const agentValidated = ref<boolean | null>(null)
|
||
const agentNameMasked = ref('')
|
||
const agentLoading = ref(false)
|
||
const referrerInfo = ref<Record<string, unknown> | null>(null)
|
||
const referrerValidated = ref<boolean | null>(null)
|
||
const referrerLoading = ref(false)
|
||
const referrerTimedOut = ref(false)
|
||
|
||
const phoneInputRef = ref<{ focus?: () => void } | null>(null)
|
||
const emailInputRef = ref<{ focus?: () => void } | null>(null)
|
||
const passwordInputRef = ref<{ focus?: () => void } | null>(null)
|
||
const confirmPasswordInputRef = ref<{ focus?: () => void } | null>(null)
|
||
|
||
const formState = reactive({
|
||
customerName: '',
|
||
phone: '',
|
||
email: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
agentCodeInput: '',
|
||
})
|
||
|
||
const fieldErrors = reactive({
|
||
customerName: '',
|
||
phone: '',
|
||
email: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
})
|
||
|
||
const effectiveAgentCode = computed(() =>
|
||
agentValidated.value === true ? formState.agentCodeInput.trim() : '',
|
||
)
|
||
|
||
const agentBanner = computed(
|
||
() => agentValidated.value === true && !!effectiveAgentCode.value,
|
||
)
|
||
|
||
const referrerBanner = computed(() => {
|
||
if (effectiveAgentCode.value) return null
|
||
if (referrerLoading.value) {
|
||
return { kind: 'loading' as const, text: t('marketing.register.referrerLoading') }
|
||
}
|
||
if (referrerInfo.value) {
|
||
const name =
|
||
(referrerInfo.value.referrerName as string | undefined) ??
|
||
(referrerInfo.value.ReferrerName as string | undefined) ??
|
||
(referrerInfo.value.customerName as string | undefined) ??
|
||
''
|
||
const code =
|
||
(referrerInfo.value.inviteCode as string | undefined) ??
|
||
(referrerInfo.value.InviteCode as string | undefined) ??
|
||
inviteCode.value
|
||
return {
|
||
kind: 'ok' as const,
|
||
text: t('marketing.register.referrerHint', { name, code }),
|
||
}
|
||
}
|
||
if (referrerTimedOut.value) {
|
||
return { kind: 'warn' as const, text: t('marketing.register.referrerTimeout') }
|
||
}
|
||
if (inviteCode.value && referrerValidated.value === false) {
|
||
return { kind: 'warn' as const, text: t('marketing.register.invalidRef') }
|
||
}
|
||
return null
|
||
})
|
||
|
||
const inviteBannerClass = computed(() => {
|
||
if (agentLoading.value || referrerBanner.value?.kind === 'loading') {
|
||
return 'nova-register__invite--info'
|
||
}
|
||
if (agentBanner.value || referrerBanner.value?.kind === 'ok') {
|
||
return 'nova-register__invite--ok'
|
||
}
|
||
if (referrerBanner.value?.kind === 'warn') {
|
||
return 'nova-register__invite--warn'
|
||
}
|
||
return ''
|
||
})
|
||
|
||
function readStoredAppearance(): boolean {
|
||
try {
|
||
return localStorage.getItem(LOGIN_APPEARANCE_KEY) === 'light'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
const isLightMode = ref(readStoredAppearance())
|
||
|
||
function toggleAppearance() {
|
||
isLightMode.value = !isLightMode.value
|
||
try {
|
||
localStorage.setItem(LOGIN_APPEARANCE_KEY, isLightMode.value ? 'light' : 'dark')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function goOfficialSite() {
|
||
window.location.href = '/'
|
||
}
|
||
|
||
function resolvePostRegisterLogin() {
|
||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect.trim() : ''
|
||
const wantH5 =
|
||
preferH5Portal() || (redirect.startsWith('/h5') && !isAdminH5Path(redirect))
|
||
if (wantH5) {
|
||
return {
|
||
path: '/h5/login',
|
||
query: redirect ? { redirect } : undefined,
|
||
}
|
||
}
|
||
return {
|
||
name: 'login' as const,
|
||
query: redirect ? { redirect } : inviteCode.value ? {} : undefined,
|
||
}
|
||
}
|
||
|
||
function focusNext(field: 'phone' | 'email' | 'password' | 'confirmPassword') {
|
||
const map = {
|
||
phone: phoneInputRef,
|
||
email: emailInputRef,
|
||
password: passwordInputRef,
|
||
confirmPassword: confirmPasswordInputRef,
|
||
}
|
||
map[field].value?.focus?.()
|
||
}
|
||
|
||
function validateForm(): boolean {
|
||
fieldErrors.customerName = formState.customerName.trim()
|
||
? ''
|
||
: t('marketing.register.customerNameRequired')
|
||
fieldErrors.phone = formState.phone.trim() ? '' : t('marketing.register.phoneRequired')
|
||
if (!formState.email.trim()) {
|
||
fieldErrors.email = t('marketing.register.emailRequired')
|
||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.email.trim())) {
|
||
fieldErrors.email = t('marketing.register.emailInvalid')
|
||
} else {
|
||
fieldErrors.email = ''
|
||
}
|
||
if (!formState.password) {
|
||
fieldErrors.password = t('marketing.register.passwordRequired')
|
||
} else if (!isLetterAndDigitPassword(formState.password)) {
|
||
fieldErrors.password = t('marketing.register.passwordInvalid')
|
||
} else {
|
||
fieldErrors.password = ''
|
||
}
|
||
if (!formState.confirmPassword) {
|
||
fieldErrors.confirmPassword = t('marketing.register.confirmPasswordRequired')
|
||
} else if (formState.confirmPassword !== formState.password) {
|
||
fieldErrors.confirmPassword = t('profile.passwordMismatch')
|
||
} else {
|
||
fieldErrors.confirmPassword = ''
|
||
}
|
||
return !Object.values(fieldErrors).some(Boolean)
|
||
}
|
||
|
||
async function validateAgent(code: string, locked: boolean) {
|
||
const trimmed = code.trim()
|
||
if (!trimmed) {
|
||
agentValidated.value = null
|
||
agentNameMasked.value = ''
|
||
return
|
||
}
|
||
agentLoading.value = true
|
||
try {
|
||
const data = await withTimeout(validateAgentCode(trimmed), AGENT_TIMEOUT_MS)
|
||
if (!data.valid) {
|
||
agentValidated.value = false
|
||
agentNameMasked.value = ''
|
||
if (locked) {
|
||
Message.warning(data.message || t('marketing.register.agentInvalid'))
|
||
}
|
||
return
|
||
}
|
||
formState.agentCodeInput = data.agentCode || trimmed
|
||
agentNameMasked.value = data.agentNameMasked || ''
|
||
agentValidated.value = true
|
||
agentLocked.value = locked
|
||
stashAgentInvite(formState.agentCodeInput, locked)
|
||
} catch {
|
||
agentValidated.value = false
|
||
agentNameMasked.value = ''
|
||
} finally {
|
||
agentLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onAgentInputBlur() {
|
||
if (agentLocked.value) return
|
||
const code = formState.agentCodeInput.trim()
|
||
if (!code) {
|
||
agentValidated.value = null
|
||
agentNameMasked.value = ''
|
||
clearStashedAgentInvite()
|
||
return
|
||
}
|
||
void validateAgent(code, false)
|
||
}
|
||
|
||
function onAgentInputClear() {
|
||
if (agentLocked.value) return
|
||
agentValidated.value = null
|
||
agentNameMasked.value = ''
|
||
clearStashedAgentInvite()
|
||
}
|
||
|
||
async function loadReferrer(code: string) {
|
||
inviteCode.value = code
|
||
stashInviteRef(code)
|
||
referrerLoading.value = true
|
||
referrerTimedOut.value = false
|
||
referrerValidated.value = null
|
||
referrerInfo.value = null
|
||
try {
|
||
const data = (await withTimeout(
|
||
validateInviteCode(code) as Promise<Record<string, unknown>>,
|
||
REFERRER_TIMEOUT_MS,
|
||
)) as Record<string, unknown>
|
||
const valid = data.valid ?? data.Valid
|
||
if (valid === false) {
|
||
referrerInfo.value = null
|
||
referrerValidated.value = false
|
||
return
|
||
}
|
||
referrerInfo.value = {
|
||
...data,
|
||
referrerName:
|
||
data.inviterNameMasked ??
|
||
data.InviterNameMasked ??
|
||
data.inviterCustomerCode ??
|
||
data.InviterCustomerCode,
|
||
InviteCode: data.inviterCustomerCode ?? data.InviterCustomerCode ?? code,
|
||
inviteCode: data.inviterCustomerCode ?? data.InviterCustomerCode ?? code,
|
||
}
|
||
referrerValidated.value = true
|
||
} catch (error) {
|
||
referrerInfo.value = null
|
||
if (isTimeoutError(error)) {
|
||
referrerTimedOut.value = true
|
||
referrerValidated.value = null
|
||
return
|
||
}
|
||
referrerValidated.value = false
|
||
} finally {
|
||
referrerLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
submitError.value = ''
|
||
if (!validateForm()) return
|
||
|
||
const typedAgent = formState.agentCodeInput.trim()
|
||
if (typedAgent && agentValidated.value !== true) {
|
||
await validateAgent(typedAgent, agentLocked.value)
|
||
if (agentValidated.value !== true) {
|
||
Message.warning(t('marketing.register.agentInvalid'))
|
||
return
|
||
}
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
await CustomerService.create({
|
||
customerName: formState.customerName.trim(),
|
||
phone: formState.phone.trim(),
|
||
email: formState.email.trim(),
|
||
password: formState.password,
|
||
...(effectiveAgentCode.value
|
||
? ({ agentCode: effectiveAgentCode.value } as Record<string, string>)
|
||
: inviteCode.value && referrerValidated.value
|
||
? ({ inviteCode: inviteCode.value } as Record<string, string>)
|
||
: {}),
|
||
...(advisorCode.value ? { advisorCode: advisorCode.value } : {}),
|
||
})
|
||
const { clearStashedInviteRef } = await import('@/utils/inviteRefStorage')
|
||
clearStashedInviteRef()
|
||
clearStashedAdvisorInvite()
|
||
clearStashedAgentInvite()
|
||
Message.success(t('marketing.register.success'))
|
||
router.replace(resolvePostRegisterLogin())
|
||
} catch (e: any) {
|
||
submitError.value = e?.message || t('marketing.register.failed')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function goLogin() {
|
||
router.push(resolvePostRegisterLogin())
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS)
|
||
|
||
// 深链 mode=wechat 且在手机/微信内:转 H5 微信注册
|
||
if (step.value === 'wechat' && (preferH5Portal() || isWeChatBrowser())) {
|
||
void router.replace({ path: '/wechat/register', query: attributionQuery() })
|
||
return
|
||
}
|
||
|
||
// 仅 URL 带 agent 才自动绑定;裸 /register 不读本地暂存,避免误绑
|
||
const agentFromQuery = String(
|
||
route.query.agent ?? route.query.agentCode ?? '',
|
||
).trim()
|
||
if (agentFromQuery) {
|
||
formState.agentCodeInput = agentFromQuery
|
||
agentLocked.value = true
|
||
stashAgentInvite(agentFromQuery, true)
|
||
void validateAgent(agentFromQuery, true)
|
||
}
|
||
|
||
const refCode = String(route.query.ref ?? route.query.invite ?? '').trim()
|
||
if (refCode && !agentFromQuery) void loadReferrer(refCode)
|
||
|
||
const advisor = resolveAdvisorInvite(route.query as Record<string, unknown>)
|
||
if (advisor) {
|
||
advisorCode.value = advisor
|
||
stashAdvisorInvite(advisor)
|
||
}
|
||
|
||
const idle = window.requestIdleCallback
|
||
? (cb: () => void, timeout = 1200) => window.requestIdleCallback(cb, { timeout })
|
||
: (cb: () => void, timeout = 1200) => window.setTimeout(cb, Math.min(timeout, 400))
|
||
idle(() => {
|
||
showCharacters.value = true
|
||
}, 1600)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.documentElement.classList.remove(LOGIN_PAGE_SCROLL_LOCK_CLASS)
|
||
})
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.login-container {
|
||
--login-split-top: 62%;
|
||
--login-split-bottom: 38%;
|
||
--login-left-bg: #142637;
|
||
--login-right-bg: var(--app-page-bg, #f0f3f4);
|
||
|
||
box-sizing: border-box;
|
||
height: 100dvh;
|
||
max-height: 100dvh;
|
||
min-height: 100dvh;
|
||
position: relative;
|
||
overflow: hidden;
|
||
overscroll-behavior: none;
|
||
}
|
||
|
||
.login-container--light {
|
||
--login-left-bg: #e8eef5;
|
||
--login-right-bg: var(--app-page-bg, #f0f3f4);
|
||
}
|
||
|
||
.login-split {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
}
|
||
|
||
.login-split--left {
|
||
background: var(--login-left-bg);
|
||
clip-path: polygon(0 0, var(--login-split-top) 0, var(--login-split-bottom) 100%, 0 100%);
|
||
}
|
||
|
||
.login-split--right {
|
||
background: var(--login-right-bg);
|
||
clip-path: polygon(
|
||
var(--login-split-top) 0,
|
||
100% 0,
|
||
100% 100%,
|
||
var(--login-split-bottom) 100%
|
||
);
|
||
}
|
||
|
||
.login-pane {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
}
|
||
|
||
.login-pane--left {
|
||
clip-path: polygon(0 0, var(--login-split-top) 0, var(--login-split-bottom) 100%, 0 100%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.login-pane--right {
|
||
clip-path: polygon(
|
||
var(--login-split-top) 0,
|
||
100% 0,
|
||
100% 100%,
|
||
var(--login-split-bottom) 100%
|
||
);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.login-pane--right .login-form {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: calc((var(--login-split-top) + var(--login-split-bottom) + 100% + 100%) / 4);
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.login-hero-panel {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: calc((var(--login-split-top) + var(--login-split-bottom)) / 4);
|
||
transform: translate(-50%, -50%);
|
||
width: min(920px, calc((var(--login-split-top) + var(--login-split-bottom)) / 2 - 24px));
|
||
max-width: calc(100% - 32px);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.login-characters-panel {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 0;
|
||
right: auto;
|
||
z-index: 6;
|
||
pointer-events: auto;
|
||
padding: 0 0 16px 8px;
|
||
}
|
||
|
||
.login-form-toolbar {
|
||
position: absolute;
|
||
top: 14px;
|
||
right: 14px;
|
||
z-index: 2;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.login-form-toolbar :deep(.theme-toggle-btn.arco-btn),
|
||
.login-form-toolbar :deep(.login-lang-toggle) {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
color: rgba(26, 45, 74, 0.72) !important;
|
||
}
|
||
|
||
.login-form-toolbar :deep(.theme-toggle-btn.arco-btn:hover),
|
||
.login-form-toolbar :deep(.login-lang-toggle:hover) {
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: var(--app-acrylic-hover-bg) !important;
|
||
color: var(--app-primary-color) !important;
|
||
}
|
||
|
||
.theme-toggle-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.login-form {
|
||
--login-card-bg: #ffffff;
|
||
--login-card-shadow:
|
||
inset 0 2px 8px rgba(15, 23, 42, 0.07),
|
||
inset 0 1px 1px rgba(15, 23, 42, 0.04);
|
||
|
||
position: relative;
|
||
z-index: 10;
|
||
width: 100%;
|
||
max-width: 460px;
|
||
padding: 44px 30px 32px;
|
||
background: var(--login-card-bg);
|
||
border: none;
|
||
border-radius: 20px;
|
||
box-shadow: var(--login-card-shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.login-form--register {
|
||
max-height: min(94dvh, 780px);
|
||
}
|
||
|
||
.login-content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
padding-right: 2px;
|
||
}
|
||
|
||
.nova-register {
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.nova-register__header {
|
||
display: block;
|
||
text-align: center;
|
||
margin-bottom: 18px;
|
||
color: inherit;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.nova-register__brand-hint {
|
||
margin: 8px 0 0;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
color: rgba(15, 39, 68, 0.55);
|
||
}
|
||
|
||
.nova-register__back {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
margin: 0 0 14px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: #86909c;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: color 0.15s ease;
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
|
||
.nova-register__choose {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.nova-register__choose-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 4px;
|
||
width: 100%;
|
||
padding: 16px 18px;
|
||
border: none;
|
||
border-radius: 14px;
|
||
background: #f0f3f4;
|
||
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.06);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition:
|
||
background 0.2s ease,
|
||
transform 0.2s ease,
|
||
box-shadow 0.2s ease;
|
||
|
||
&:hover {
|
||
background: color-mix(in srgb, var(--app-primary-color) 8%, #f0f3f4);
|
||
box-shadow:
|
||
inset 0 2px 8px rgba(15, 23, 42, 0.06),
|
||
0 0 0 2px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
&:active {
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.nova-register__choose-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
margin-bottom: 6px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.nova-register__choose-icon--web {
|
||
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
|
||
color: var(--app-primary-color);
|
||
}
|
||
|
||
.nova-register__choose-icon--wechat {
|
||
background: color-mix(in srgb, #07c160 16%, #fff);
|
||
color: #07c160;
|
||
}
|
||
|
||
.nova-register__choose-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #0f2744;
|
||
}
|
||
|
||
.nova-register__choose-desc {
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
color: #86909c;
|
||
}
|
||
|
||
.nova-register__invite {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin: 4px 0 12px;
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
background: #f0f3f4;
|
||
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.nova-register__invite--ok {
|
||
background: color-mix(in srgb, var(--app-primary-color) 7%, #f0f3f4);
|
||
border: none;
|
||
color: #0f2744;
|
||
}
|
||
|
||
.nova-register__invite--info {
|
||
background: #f0f3f4;
|
||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||
color: #64748b;
|
||
}
|
||
|
||
.nova-register__invite--warn {
|
||
background: #fff7ed;
|
||
border: 1px solid #fed7aa;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.nova-register__invite-label {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--app-primary-color);
|
||
}
|
||
|
||
.nova-register__invite--warn .nova-register__invite-label {
|
||
color: #c2410c;
|
||
}
|
||
|
||
.nova-register__invite-text {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.nova-register__invite-dot {
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 6px;
|
||
margin-right: 6px;
|
||
border-radius: 50%;
|
||
background: var(--app-primary-color);
|
||
vertical-align: middle;
|
||
animation: invite-pulse 1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes invite-pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 0.35;
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.nova-register__alert {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.nova-register__form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.nova-register__row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.nova-register__input-group {
|
||
margin-bottom: 14px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.nova-register__field-label {
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #334155;
|
||
letter-spacing: 0.3px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.nova-register__form :deep(.arco-input-wrapper),
|
||
.nova-register__form :deep(.arco-input-password) {
|
||
border-radius: 12px;
|
||
background: #f0f3f4;
|
||
border: none;
|
||
min-height: 44px;
|
||
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.08);
|
||
transition: box-shadow 0.2s ease, background 0.2s ease;
|
||
|
||
&:hover {
|
||
background: #f0f3f4;
|
||
box-shadow: inset 0 3px 10px rgba(15, 23, 42, 0.1);
|
||
}
|
||
|
||
&.arco-input-wrapper-focus,
|
||
&.arco-input-focus {
|
||
background: #f0f3f4;
|
||
border: none;
|
||
box-shadow:
|
||
inset 0 3px 10px rgba(15, 23, 42, 0.1),
|
||
0 0 0 3px color-mix(in srgb, var(--app-primary-color) 12%, transparent);
|
||
}
|
||
}
|
||
|
||
.nova-register__form :deep(input.arco-input):-webkit-autofill,
|
||
.nova-register__form :deep(input.arco-input):-webkit-autofill:hover,
|
||
.nova-register__form :deep(input.arco-input):-webkit-autofill:focus,
|
||
.nova-register__form :deep(input.arco-input):-webkit-autofill:active {
|
||
-webkit-text-fill-color: #1e293b;
|
||
caret-color: #1e293b;
|
||
border: none;
|
||
-webkit-box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||
box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||
transition: background-color 99999s ease-out 0s;
|
||
}
|
||
|
||
.nova-register__arco-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.nova-register__field-error {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.nova-register__error-box {
|
||
padding: 11px 14px;
|
||
font-size: 13px;
|
||
color: #b91c1c;
|
||
background: color-mix(in srgb, #fff1f2 72%, var(--app-acrylic-bg));
|
||
border: 1px solid color-mix(in srgb, #fecdd3 80%, var(--app-acrylic-border));
|
||
border-radius: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.nova-register__submit {
|
||
width: 100%;
|
||
height: 48px;
|
||
margin-top: 2px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
border-radius: 14px;
|
||
background: var(--app-primary-color);
|
||
border: 1px solid color-mix(in srgb, var(--app-primary-color) 80%, transparent);
|
||
color: #fff;
|
||
letter-spacing: 0.5px;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, background 0.2s, opacity 0.2s;
|
||
font-family: inherit;
|
||
|
||
&:hover:not(:disabled) {
|
||
transform: translateY(-1px);
|
||
background: var(--app-primary-color-hover);
|
||
}
|
||
|
||
&:active:not(:disabled) {
|
||
transform: translateY(1px);
|
||
opacity: 0.85;
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
.nova-register__footer {
|
||
margin: 16px 0 0;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: #86909c;
|
||
|
||
:deep(.arco-link) {
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 1024px) {
|
||
.login-characters-panel {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 1250px) {
|
||
.login-container {
|
||
--login-split-top: 0%;
|
||
--login-split-bottom: 0%;
|
||
}
|
||
|
||
.login-split--left,
|
||
.login-pane--left {
|
||
display: none;
|
||
}
|
||
|
||
.login-pane--right .login-form {
|
||
position: relative;
|
||
top: auto;
|
||
left: auto;
|
||
transform: none;
|
||
margin: auto;
|
||
}
|
||
|
||
.login-split--right,
|
||
.login-pane--right {
|
||
clip-path: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.login-form--register {
|
||
max-height: none;
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 600px) {
|
||
.login-form {
|
||
max-width: 97% !important;
|
||
padding: 36px 18px 24px;
|
||
}
|
||
|
||
.nova-register__row {
|
||
grid-template-columns: 1fr;
|
||
gap: 0;
|
||
}
|
||
}
|
||
</style>
|