front/src/views/register/index.vue
zzh 351b465d5b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m11s
feat: 客户注册对齐登录壳,并拆分网页/微信注册入口
登录仅保留账号与微信;注册先选渠道,代理码仅 URL 携带时自动绑定。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:07:23 +08:00

1227 lines
35 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.

<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>