文件
OmniCard/mpuniapp/pages/common/login.uvue

899 行
21 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="login-page" :class="pageRtlClass" :style="pageTheme">
<!-- 顶部极光弥散微光晕背景 (受控光晕 < 8%) -->
<view class="aurora-glow-spot"></view>
<!-- 顶部导航轻工具条 -->
<view class="top-nav-bar">
<view
class="nav-back-action"
role="button"
aria-label="Back"
@click="handleGoBack"
>
<text class="nav-back-arrow">‹</text>
</view>
<view class="nav-locale-action">
<i18n-selector :model-value="currentLocale" @change="handleLocaleChange" />
</view>
</view>
<!-- 主视口内容容器 -->
<view class="login-viewport">
<!-- 品牌 Hero 模组 -->
<view class="brand-hero">
<view class="logo-box">
<image class="brand-logo" src="/static/logo.svg" mode="aspectFit" />
</view>
<view class="brand-badge">
<view class="badge-dot"></view>
<text class="badge-text">{{ t('brandTag') }}</text>
</view>
<text class="brand-title">{{ t('title') }}</text>
<text class="brand-subtitle">{{ t('subtitle') }}</text>
</view>
<!-- Bento Grid 纯白质感主卡片 -->
<view class="bento-auth-card">
<!-- 分段式认证模式切换器 (Segmented Tab Bar) -->
<view class="segmented-tabs" role="tablist">
<view
class="segment-item"
:class="{ 'segment-item-active': state.tab === 'password' }"
role="tab"
:aria-selected="state.tab === 'password'"
@click="handleSwitchTab('password')"
>
<text class="segment-label" :class="{ 'segment-label-active': state.tab === 'password' }">
{{ t('tabPassword') }}
</text>
</view>
<view
class="segment-item"
:class="{ 'segment-item-active': state.tab === 'phone' }"
role="tab"
:aria-selected="state.tab === 'phone'"
@click="handleSwitchTab('phone')"
>
<text class="segment-label" :class="{ 'segment-label-active': state.tab === 'phone' }">
{{ t('tabPhone') }}
</text>
</view>
<view
class="segment-item"
:class="{ 'segment-item-active': state.tab === 'email' }"
role="tab"
:aria-selected="state.tab === 'email'"
@click="handleSwitchTab('email')"
>
<text class="segment-label" :class="{ 'segment-label-active': state.tab === 'email' }">
{{ t('tabEmail') }}
</text>
</view>
</view>
<!-- 动态认证表单模组 -->
<view class="form-content">
<!-- 1. 账号密码登录模式 -->
<view v-if="state.tab === 'password'" class="tab-panel">
<view class="field-item">
<input
class="field-input"
type="text"
v-model="state.username"
:placeholder="t('usernamePlaceholder')"
:disabled="state.submitting"
/>
</view>
<view class="field-item">
<input
class="field-input"
type="password"
v-model="state.password"
:placeholder="t('passwordPlaceholder')"
:disabled="state.submitting"
/>
</view>
<!-- 快捷体验测试账号 Bento 胶囊 -->
<view
class="quick-demo-pill"
role="button"
:aria-label="t('testAccountTip')"
@click="handleAutofillTest"
>
<text class="demo-tag">DEMO</text>
<text class="demo-text">{{ t('testAccountTip') }}</text>
</view>
</view>
<!-- 2. 国际手机号验证码模式 -->
<view v-else-if="state.tab === 'phone'" class="tab-panel">
<view class="field-item phone-row">
<view class="country-pill">
<text class="country-text">{{ state.countryCode }}</text>
<text class="country-arrow">▾</text>
</view>
<input
class="field-input phone-input"
type="number"
v-model="state.phone"
:placeholder="t('phonePlaceholder')"
:disabled="state.submitting"
/>
</view>
<view class="field-item sms-row">
<input
class="field-input sms-input"
type="number"
v-model="state.smsCode"
:placeholder="t('smsCode')"
:disabled="state.submitting"
/>
<button
class="sms-send-btn"
:class="{ 'sms-send-btn-disabled': state.countdown > 0 }"
:disabled="state.countdown > 0"
@click="handleSendSmsCode"
>
<text class="sms-send-text">{{ smsBtnText }}</text>
</button>
</view>
</view>
<!-- 3. 工作邮箱 Magic Link 模式 -->
<view v-else-if="state.tab === 'email'" class="tab-panel">
<view class="field-item">
<input
class="field-input"
type="text"
v-model="state.email"
:placeholder="t('emailPlaceholder')"
:disabled="state.submitting"
/>
</view>
</view>
<!-- 错误提示柔和条 -->
<view v-if="state.errorMessage !== ''" class="error-banner">
<view class="error-icon-box">
<text class="error-icon-mark">!</text>
</view>
<text class="error-text">{{ state.errorMessage }}</text>
</view>
<!-- 单屏唯一醒目主 CTA -->
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-loading': state.submitting }"
role="button"
:aria-label="loginAriaLabel"
@click="handlePrimarySubmit"
>
<text class="primary-cta-text">{{ loginCtaLabel }}</text>
</button>
<!-- 国际标配社交登录入口 -->
<view class="social-divider">
<view class="divider-line"></view>
<text class="divider-text">{{ t('orContinueWith') }}</text>
<view class="divider-line"></view>
</view>
<view class="social-actions">
<view
class="social-pill"
role="button"
:aria-label="t('google')"
@click="handleSocialLogin('google')"
>
<text class="social-badge-icon google-icon">G</text>
<text class="social-pill-text">{{ t('google') }}</text>
</view>
<view
class="social-pill"
role="button"
:aria-label="t('apple')"
@click="handleSocialLogin('apple')"
>
<text class="social-badge-icon apple-icon"></text>
<text class="social-pill-text">{{ t('apple') }}</text>
</view>
</view>
</view>
</view>
<!-- 底部合规与协议说明 -->
<view class="footer-legal">
<text class="legal-text">{{ t('termsNotice') }}</text>
<text class="legal-link" @click="handleOpenTerms">{{ t('termsOfService') }}</text>
<text class="legal-text">{{ t('and') }}</text>
<text class="legal-link" @click="handleOpenPrivacy">{{ t('privacyPolicy') }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import { getCurrentLocale, isRtl, setAppLocale, OmniLocale } from '../../common/locale.uts';
import {
createLoginState,
submitPasswordLogin,
submitPhoneLogin,
sendMagicLink,
sendSmsCode,
tapSocialLogin,
fillTestAccount,
tickCountdown,
switchLoginTab,
resolveLoginText,
LoginTab,
SocialProvider,
LoginState,
} from './useLogin.uts';
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-gradient': utsLightColors.primaryGradient,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-aurora': utsLightColors.aurora,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-success': utsLightColors.success,
'--oc-warning': utsLightColors.warning,
'--oc-error': utsLightColors.error,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-radius-modal': utsRadius.modal,
'--oc-radius-pill': utsRadius.pill,
'--oc-shadow-card': utsTypography.shadowCard,
'--oc-shadow-cta': utsTypography.shadowCta,
'--oc-shadow-float': utsTypography.shadowFloat,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
'--oc-space-20': utsSpacing.space20,
'--oc-space-24': utsSpacing.space24,
};
const state = reactive(createLoginState());
const currentLocale = ref<string>(getCurrentLocale());
let countdownTimerId: number | null = null;
const pageRtlClass = computed((): string => {
return isRtl(currentLocale.value) ? 'login-rtl' : '';
});
const loginCtaLabel = computed((): string => {
if (state.submitting) {
return t('loggingIn');
}
if (state.tab === 'email') {
return t('magicLink');
}
return t('login');
});
const loginAriaLabel = computed((): string => {
return loginCtaLabel.value;
});
const smsBtnText = computed((): string => {
if (state.countdown > 0) {
return `${state.countdown}${t('countdownSuffix')}`;
}
return t('sendCode');
});
function t(key: string): string {
const _ = currentLocale.value;
return resolveLoginText(key, currentLocale.value);
}
function handleLocaleChange(loc: OmniLocale): void {
setAppLocale(loc);
currentLocale.value = loc;
}
function handleSwitchTab(tab: LoginTab): void {
switchLoginTab(state, tab);
}
function handleAutofillTest(): void {
fillTestAccount(state);
}
function handleSendSmsCode(): void {
const ok = sendSmsCode(state);
if (ok) {
startCountdown();
}
}
function startCountdown(): void {
if (countdownTimerId != null) {
return;
}
countdownTimerId = setInterval(() => {
tickCountdown(state);
if (state.countdown <= 0) {
stopCountdown();
}
}, 1000);
}
function stopCountdown(): void {
if (countdownTimerId != null) {
clearInterval(countdownTimerId);
countdownTimerId = null;
}
}
function handlePrimarySubmit(): void {
if (state.tab === 'password') {
submitPasswordLogin(state);
} else if (state.tab === 'phone') {
submitPhoneLogin(state);
} else if (state.tab === 'email') {
sendMagicLink(state);
}
}
function handleSocialLogin(provider: SocialProvider): void {
tapSocialLogin(provider);
}
function handleGoBack(): void {
try {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
return;
}
} catch (e) {}
uni.switchTab({
url: '/pages/owner/card/index',
});
}
function handleOpenTerms(): void {
uni.showToast({
title: t('termsOfService'),
icon: 'none',
});
}
function handleOpenPrivacy(): void {
uni.showToast({
title: t('privacyPolicy'),
icon: 'none',
});
}
onUnmounted(() => {
stopCountdown();
});
</script>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
background-color: var(--oc-bg, #F5F8FC);
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding-inline: var(--oc-space-16, 16px);
padding-top: 16px;
padding-bottom: calc(36px + env(safe-area-inset-bottom, 0px));
overflow-x: hidden;
}
.login-rtl {
direction: rtl;
}
/* 受控极光弥散微光斑 (< 8% 视口面积) */
.aurora-glow-spot {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
width: 280px;
height: 220px;
background: linear-gradient(135deg, rgba(0, 210, 255, 0.16) 0%, rgba(0, 136, 255, 0.12) 50%, rgba(99, 102, 241, 0.08) 100%);
filter: blur(40px);
border-radius: 9999px;
pointer-events: none;
z-index: 0;
}
/* 顶部导航工具栏 (触控热区 >= 44x44) */
.top-nav-bar {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 12px;
}
.nav-back-action {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid var(--oc-border, #EBF1F7);
}
.nav-back-action:active {
background-color: #FFFFFF;
}
.nav-back-arrow {
font-size: 26px;
color: var(--oc-text, #101828);
line-height: 1;
}
.nav-locale-action {
display: flex;
align-items: center;
}
/* 主视口居中容器 (移动端限制 400px 以内自适应) */
.login-viewport {
position: relative;
z-index: 1;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
}
/* 品牌 Hero 模组 */
.brand-hero {
display: flex;
flex-direction: column;
align-items: center;
margin-block-end: 20px;
}
.logo-box {
width: 56px;
height: 56px;
background-color: var(--oc-surface, #FFFFFF);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0, 136, 255, 0.1);
border: 1px solid rgba(235, 241, 247, 0.8);
margin-block-end: 12px;
}
.brand-logo {
width: 38px;
height: 38px;
}
.brand-badge {
display: flex;
flex-direction: row;
align-items: center;
padding-inline: 10px 12px;
padding-block: 4px;
background-color: var(--oc-primary-soft, #EBF5FF);
border-radius: 9999px;
margin-block-end: 10px;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: 6px;
}
.badge-text {
font-size: 11px;
font-weight: 600;
color: var(--oc-primary, #0088FF);
letter-spacing: 0.02em;
}
.brand-title {
font-size: 24px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
letter-spacing: -0.02em;
text-align: center;
margin-block-end: 6px;
}
.brand-subtitle {
font-size: 13px;
color: var(--oc-text-2, #64748B);
line-height: 1.5;
text-align: center;
}
/* Bento Grid 纯白质感主卡片容器 */
.bento-auth-card {
width: 100%;
background-color: var(--oc-surface, #FFFFFF);
border-radius: var(--oc-radius-card, 20px);
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(11, 99, 229, 0.03);
padding: 20px 18px;
box-sizing: border-box;
}
/* 分段式 Tab 切换器 (Segmented Control) */
.segmented-tabs {
display: flex;
flex-direction: row;
background-color: #F1F5F9;
border-radius: 12px;
padding: 3px;
margin-block-end: 18px;
}
.segment-item {
flex: 1;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
transition: all 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.segment-item-active {
background-color: #FFFFFF;
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.06);
}
.segment-label {
font-size: 13px;
font-weight: 500;
color: var(--oc-text-2, #64748B);
}
.segment-label-active {
color: var(--oc-text, #101828);
font-weight: 600;
}
/* 表单主体内容区 */
.form-content {
display: flex;
flex-direction: column;
}
.tab-panel {
display: flex;
flex-direction: column;
}
.field-item {
margin-block-end: 12px;
}
.field-input {
width: 100%;
height: 46px;
padding-inline: 14px;
font-size: 15px;
color: var(--oc-text, #101828);
background-color: #F8FAFC;
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 12px;
box-sizing: border-box;
transition: border-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.field-input:focus {
background-color: #FFFFFF;
border-color: var(--oc-primary, #0088FF);
}
/* 快捷测试账号 Bento 胶囊 */
.quick-demo-pill {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F8FAFC;
border: 1px dashed #CBD5E1;
border-radius: 10px;
padding: 8px 12px;
min-height: 40px;
margin-block-end: 14px;
box-sizing: border-box;
}
.quick-demo-pill:active {
background-color: var(--oc-primary-soft, #EBF5FF);
border-color: var(--oc-primary, #0088FF);
}
.demo-tag {
font-size: 10px;
font-weight: 700;
color: #FFFFFF;
background-color: var(--oc-primary, #0088FF);
padding-inline: 6px;
padding-block: 2px;
border-radius: 4px;
margin-inline-end: 8px;
letter-spacing: 0.04em;
}
.demo-text {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
flex: 1;
}
/* 手机号复合输入行 */
.phone-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.country-pill {
width: 72px;
height: 46px;
background-color: #F8FAFC;
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
}
.country-text {
font-size: 14px;
font-weight: 500;
color: var(--oc-text, #101828);
}
.country-arrow {
font-size: 10px;
color: var(--oc-text-2, #64748B);
}
.phone-input {
flex: 1;
}
/* 验证码复合输入行 */
.sms-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.sms-input {
flex: 1;
}
.sms-send-btn {
width: 108px;
height: 46px;
background-color: var(--oc-primary-soft, #EBF5FF);
border: 1px solid rgba(0, 136, 255, 0.2);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.sms-send-btn::after {
border: none;
}
.sms-send-btn:active {
opacity: 0.85;
}
.sms-send-btn-disabled {
background-color: #F1F5F9;
border-color: #E2E8F0;
}
.sms-send-text {
font-size: 13px;
font-weight: 600;
color: var(--oc-primary, #0088FF);
}
.sms-send-btn-disabled .sms-send-text {
color: #94A3B8;
}
/* 柔和错误提示条 */
.error-banner {
display: flex;
flex-direction: row;
align-items: center;
background-color: #FEF3F2;
border: 1px solid #FECDCA;
border-radius: 10px;
padding: 8px 12px;
margin-block-end: 14px;
}
.error-icon-box {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--oc-error, #F04438);
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: 8px;
}
.error-icon-mark {
color: #FFFFFF;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.error-text {
font-size: 12px;
color: #B42318;
line-height: 1.4;
flex: 1;
}
/* 单屏唯一核心主 CTA 按钮 */
.primary-cta-btn {
width: 100%;
height: 48px;
min-height: 44px;
background: linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%);
border-radius: var(--oc-radius-btn, 14px);
border-width: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 20px rgba(0, 136, 255, 0.22);
margin-block-end: 18px;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn::after {
border: none;
}
.primary-cta-btn:active {
opacity: 0.88;
}
.primary-cta-btn-loading {
opacity: 0.7;
}
.primary-cta-text {
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* 分割线与国际社交快捷通道 */
.social-divider {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: 14px;
}
.divider-line {
flex: 1;
height: 1px;
background-color: var(--oc-border, #EBF1F7);
}
.divider-text {
font-size: 11px;
color: var(--oc-text-2, #94A3B8);
padding-inline: 10px;
}
.social-actions {
display: flex;
flex-direction: row;
gap: 10px;
}
.social-pill {
flex: 1;
height: 42px;
min-height: 44px;
background-color: #F8FAFC;
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
}
.social-pill:active {
background-color: #F1F5F9;
}
.social-badge-icon {
font-size: 15px;
font-weight: 700;
color: var(--oc-text, #101828);
}
.social-pill-text {
font-size: 13px;
font-weight: 500;
color: var(--oc-text, #101828);
}
/* 底部合规协议区 */
.footer-legal {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-block-start: 22px;
}
.legal-text {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.6;
}
.legal-link {
font-size: 12px;
color: var(--oc-primary, #0088FF);
text-decoration: underline;
margin-inline: 4px;
line-height: 1.6;
}
/* 动效适配 */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>