style(mpuniapp): 移动浏览器全量页面UI/UX审视与瑞士国际主义排版优化
这个提交包含在:
@@ -1,43 +1,60 @@
|
||||
<template>
|
||||
<view class="login-page" :style="pageTheme">
|
||||
<scroll-view class="login-scroll" :scroll-y="true">
|
||||
<view class="login-content">
|
||||
<view class="login-card">
|
||||
<text class="login-title">{{ t('title') }}</text>
|
||||
<text class="login-desc">{{ t('desc') }}</text>
|
||||
<view class="login-viewport" :class="{ 'is-rtl': isRtlMode }" :style="pageTheme">
|
||||
<!-- 顶部状态与功能栏 (返回 + 语言选择) -->
|
||||
<view class="top-nav-bar">
|
||||
<view class="nav-back-btn" role="button" aria-label="Back" @click="handleGoBack">
|
||||
<text class="nav-back-arrow">←</text>
|
||||
</view>
|
||||
<view class="nav-locale-wrap">
|
||||
<i18n-selector :model-value="currentLang" @change="handleLangChange" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="login-tabs">
|
||||
<!-- 主体内容滚动区 (消除多层内部滚动条) -->
|
||||
<view class="login-scroll">
|
||||
<view class="login-wrapper">
|
||||
<!-- 瑞士国际主义极简卡片容器 (宽屏限宽 440px 居中,移动端 100% 弹性无缝) -->
|
||||
<view class="login-card">
|
||||
<!-- 1. 品牌感知区 (Brand Hero) -->
|
||||
<view class="brand-hero">
|
||||
<image class="brand-logo" src="../../static/logo.svg" mode="aspectFit" />
|
||||
<text class="brand-name">OmniCard AI</text>
|
||||
<view class="brand-tag-capsule">
|
||||
<text class="brand-tag-text">{{ t('brandTag') }}</text>
|
||||
</view>
|
||||
<text class="brand-subtitle">{{ t('subtitle') }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 2. 极简分段控制器 (Segmented Control) -->
|
||||
<view class="segmented-box">
|
||||
<view
|
||||
class="login-tab"
|
||||
:class="{ 'login-tab-active': state.tab === 'phone' }"
|
||||
class="segment-item"
|
||||
:class="{ 'segment-active': state.tab === 'phone' }"
|
||||
@click="handleSwitchTab('phone')"
|
||||
>
|
||||
<text class="login-tab-text">{{ t('tabPhone') }}</text>
|
||||
<text class="segment-text">{{ t('tabPhone') }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="login-tab"
|
||||
:class="{ 'login-tab-active': state.tab === 'email' }"
|
||||
class="segment-item"
|
||||
:class="{ 'segment-active': state.tab === 'email' }"
|
||||
@click="handleSwitchTab('email')"
|
||||
>
|
||||
<text class="login-tab-text">{{ t('tabEmail') }}</text>
|
||||
<text class="segment-text">{{ t('tabEmail') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="state.tab === 'phone'" class="login-panel">
|
||||
<view class="phone-row">
|
||||
<view class="field field-code">
|
||||
<text class="field-label">{{ t('countryCode') }}</text>
|
||||
<!-- 3. 表单主体 -->
|
||||
<view v-if="state.tab === 'phone'" class="form-section">
|
||||
<!-- 手机号与区号一体化输入组 -->
|
||||
<view class="form-field">
|
||||
<text class="field-label">{{ t('tabPhone') }}</text>
|
||||
<view class="phone-group">
|
||||
<view class="country-pill" @click="handleSwitchCountry">
|
||||
<text class="country-text">{{ state.countryCode }}</text>
|
||||
<text class="country-arrow">▾</text>
|
||||
</view>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
v-model="state.countryCode"
|
||||
:disabled="state.submitting"
|
||||
/>
|
||||
</view>
|
||||
<view class="field field-phone">
|
||||
<text class="field-label">{{ t('phonePlaceholder') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
class="input-control phone-input"
|
||||
type="tel"
|
||||
v-model="state.phone"
|
||||
:placeholder="t('phonePlaceholder')"
|
||||
@@ -46,33 +63,34 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="send-code-row">
|
||||
<button
|
||||
class="secondary-btn"
|
||||
:disabled="state.submitting"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
<text class="secondary-btn-text">{{ t('sendCode') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<!-- 行内一体化验证码输入行 -->
|
||||
<view class="form-field">
|
||||
<text class="field-label">{{ t('smsCode') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
v-model="state.smsCode"
|
||||
:placeholder="t('smsCode')"
|
||||
:disabled="state.submitting"
|
||||
/>
|
||||
<view class="code-group">
|
||||
<input
|
||||
class="input-control code-input"
|
||||
type="number"
|
||||
v-model="state.smsCode"
|
||||
:placeholder="t('smsCode')"
|
||||
:disabled="state.submitting"
|
||||
/>
|
||||
<view
|
||||
class="inline-code-btn"
|
||||
:class="{ 'inline-code-disabled': state.countdown > 0 || state.submitting }"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
<text class="inline-code-text">{{ codeButtonText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="login-panel">
|
||||
<view class="field">
|
||||
<view v-else class="form-section">
|
||||
<!-- 邮箱输入行 -->
|
||||
<view class="form-field">
|
||||
<text class="field-label">{{ t('tabEmail') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
class="input-control text-input"
|
||||
type="text"
|
||||
v-model="state.email"
|
||||
:placeholder="t('emailPlaceholder')"
|
||||
@@ -80,52 +98,66 @@
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="social-block">
|
||||
<button class="secondary-btn social-btn" @click="handleSocial('google')">
|
||||
<text class="secondary-btn-text">{{ t('google') }}</text>
|
||||
</button>
|
||||
<button class="secondary-btn social-btn" @click="handleSocial('apple')">
|
||||
<text class="secondary-btn-text">{{ t('apple') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<!-- 错误提示 -->
|
||||
<view v-if="state.errorMessage" class="error-banner">
|
||||
<text class="error-banner-text">{{ state.errorMessage }}</text>
|
||||
</view>
|
||||
|
||||
<view class="login-bottom-spacer"></view>
|
||||
<!-- 4. 单一主 CTA 按钮 (严格符合 AGENTS.md 规范) -->
|
||||
<button
|
||||
class="primary-cta-btn"
|
||||
:class="{ 'primary-cta-disabled': state.submitting }"
|
||||
:disabled="state.submitting"
|
||||
@click="handlePrimarySubmit"
|
||||
>
|
||||
<text class="primary-cta-text">{{ primaryButtonText }}</text>
|
||||
</button>
|
||||
|
||||
<!-- 5. 快捷社交登录 (极简中性分割) -->
|
||||
<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-row">
|
||||
<view class="social-capsule" role="button" @click="handleSocial('google')">
|
||||
<text class="social-icon">G</text>
|
||||
<text class="social-name">Google</text>
|
||||
</view>
|
||||
<view class="social-capsule" role="button" @click="handleSocial('apple')">
|
||||
<text class="social-icon-apple"></text>
|
||||
<text class="social-name">Apple</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 6. 法律合规与版权声明 -->
|
||||
<view class="legal-footer">
|
||||
<text class="legal-text">{{ t('termsNotice') }}</text>
|
||||
<view class="legal-links-row">
|
||||
<text class="legal-link" @click="handleOpenTerms">{{ t('termsOfService') }}</text>
|
||||
<text class="legal-and"> {{ t('and') }} </text>
|
||||
<text class="legal-link" @click="handleOpenPrivacy">{{ t('privacyPolicy') }}</text>
|
||||
</view>
|
||||
<text class="copyright-line">© OmniCard AI. All rights reserved.</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="login-cta-bar">
|
||||
<button
|
||||
v-if="state.tab === 'phone'"
|
||||
class="primary-cta-btn"
|
||||
:class="{ 'primary-cta-btn-disabled': state.submitting }"
|
||||
:disabled="state.submitting"
|
||||
@click="handlePhoneLogin"
|
||||
>
|
||||
<text class="primary-cta-btn-text">{{ t('login') }}</text>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="primary-cta-btn"
|
||||
:class="{ 'primary-cta-btn-disabled': state.submitting }"
|
||||
:disabled="state.submitting"
|
||||
@click="handleMagicLink"
|
||||
>
|
||||
<text class="primary-cta-btn-text">{{ t('magicLink') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
|
||||
import { getCurrentLocale, isRtlLocale, OmniLocale, setAppLocale } from '../../common/locale.uts';
|
||||
import {
|
||||
createLoginState,
|
||||
switchLoginTab,
|
||||
submitPhoneLogin,
|
||||
sendMagicLink,
|
||||
sendSmsCode,
|
||||
tickCountdown,
|
||||
tapSocialLogin,
|
||||
resolveLoginText,
|
||||
LoginCopyKey,
|
||||
@@ -147,218 +179,566 @@ const pageTheme: Record<string, string> = {
|
||||
'--oc-space-12': utsSpacing.space12,
|
||||
'--oc-space-16': utsSpacing.space16,
|
||||
'--oc-space-24': utsSpacing.space24,
|
||||
'--oc-space-32': utsSpacing.space32,
|
||||
};
|
||||
|
||||
const state = reactive(createLoginState());
|
||||
const currentLang = ref<string>(getCurrentLocale());
|
||||
|
||||
function t(key: LoginCopyKey): string {
|
||||
const isRtlMode = computed((): boolean => {
|
||||
return isRtlLocale(currentLang.value);
|
||||
});
|
||||
|
||||
function t(key: LoginCopyKey | string): string {
|
||||
return resolveLoginText(key);
|
||||
}
|
||||
|
||||
const codeButtonText = computed((): string => {
|
||||
if (state.countdown > 0) {
|
||||
return `${state.countdown}${t('countdownSuffix')}`;
|
||||
}
|
||||
return t('sendCode');
|
||||
});
|
||||
|
||||
const primaryButtonText = computed((): string => {
|
||||
return state.tab === 'phone' ? t('login') : t('magicLink');
|
||||
});
|
||||
|
||||
let countdownTimer: number | null = null;
|
||||
|
||||
function runTimer(): void {
|
||||
if (countdownTimer != null) {
|
||||
return;
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
if (state.countdown > 0) {
|
||||
tickCountdown(state);
|
||||
} else if (countdownTimer != null) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleGoBack(): void {
|
||||
try {
|
||||
uni.navigateBack({
|
||||
fail: () => {
|
||||
uni.switchTab({
|
||||
url: '/pages/portal/index',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (_e) {
|
||||
uni.switchTab({
|
||||
url: '/pages/portal/index',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleLangChange(locale: OmniLocale): void {
|
||||
currentLang.value = locale;
|
||||
setAppLocale(locale);
|
||||
}
|
||||
|
||||
function handleSwitchTab(tab: LoginTab): void {
|
||||
switchLoginTab(state, tab);
|
||||
}
|
||||
|
||||
const commonCountryCodes: string[] = ['+86', '+1', '+44', '+971', '+65', '+81', '+49', '+33'];
|
||||
|
||||
function handleSwitchCountry(): void {
|
||||
uni.showActionSheet({
|
||||
itemList: commonCountryCodes,
|
||||
success: (res) => {
|
||||
const idx = res.tapIndex;
|
||||
if (idx >= 0 && idx < commonCountryCodes.length) {
|
||||
state.countryCode = commonCountryCodes[idx];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSendCode(): void {
|
||||
sendSmsCode(state);
|
||||
const sent = sendSmsCode(state);
|
||||
if (sent) {
|
||||
runTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhoneLogin(): void {
|
||||
submitPhoneLogin(state);
|
||||
}
|
||||
|
||||
function handleMagicLink(): void {
|
||||
sendMagicLink(state);
|
||||
function handlePrimarySubmit(): void {
|
||||
if (state.tab === 'phone') {
|
||||
submitPhoneLogin(state);
|
||||
} else {
|
||||
sendMagicLink(state);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSocial(provider: SocialProvider): void {
|
||||
tapSocialLogin(provider);
|
||||
}
|
||||
|
||||
function handleOpenTerms(): void {
|
||||
uni.showToast({
|
||||
title: resolveLoginText('termsOfService'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenPrivacy(): void {
|
||||
uni.showToast({
|
||||
title: resolveLoginText('privacyPolicy'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer != null) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
/* 全局视口与页面背景 */
|
||||
.login-viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 顶部轻量导航条 (44px 标准触控高) */
|
||||
.top-nav-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-back-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E3E7ED;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.nav-back-arrow {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.nav-locale-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 滚动容器与居中外层 */
|
||||
.login-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-inline: var(--oc-space-16, 16px);
|
||||
padding-block: var(--oc-space-16, 16px);
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 16px calc(48px + env(safe-area-inset-bottom, 0px)) 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 瑞士国际主义极简卡片容器 (宽屏限宽 420px,移动端 100% 自适应) */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background-color: var(--oc-bg, #FFFFFF);
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
border-radius: var(--oc-radius-card, 16px);
|
||||
padding: var(--oc-space-24, 24px) 20px;
|
||||
padding: 24px 22px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.04);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
/* 1. 品牌感知区 (Brand Hero) */
|
||||
.brand-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
margin-bottom: var(--oc-space-8, 8px);
|
||||
color: #101828;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
font-size: 14px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--oc-space-24, 24px);
|
||||
.brand-tag-capsule {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
background-color: #EAF1FF;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
.brand-tag-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0B5CFF;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #667085;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 2. 极简分段控制器 (Segmented Control) */
|
||||
.segmented-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
margin-bottom: var(--oc-space-24, 24px);
|
||||
overflow: hidden;
|
||||
background-color: #F2F4F7;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
.segment-item {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--oc-bg, #FFFFFF);
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
transition: background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.login-tab-active {
|
||||
background-color: var(--oc-primary-soft, #EAF1FF);
|
||||
.segment-active {
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
.login-tab-text {
|
||||
.segment-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.segment-active .segment-text {
|
||||
color: #101828;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
.login-tab-active .login-tab-text {
|
||||
color: var(--oc-primary, #0B5CFF);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
/* 3. 表单结构 */
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.phone-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.field {
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: var(--oc-space-16, 16px);
|
||||
}
|
||||
|
||||
.field-code {
|
||||
width: 88px;
|
||||
margin-inline-end: var(--oc-space-12, 12px);
|
||||
}
|
||||
|
||||
.field-phone {
|
||||
flex: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
color: var(--oc-text, #101828);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--oc-space-8, 8px);
|
||||
color: #101828;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-control,
|
||||
.field-input {
|
||||
height: 44px;
|
||||
padding-inline: 12px;
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
font-size: 14px;
|
||||
color: var(--oc-text, #101828);
|
||||
font-size: 16px;
|
||||
color: #101828;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.send-code-row {
|
||||
margin-bottom: var(--oc-space-16, 16px);
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0 14px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
height: 44px;
|
||||
background-color: var(--oc-bg, #FFFFFF);
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
.text-input:focus {
|
||||
border-color: #0B5CFF;
|
||||
}
|
||||
|
||||
/* 手机号复合输入组 */
|
||||
.phone-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 12px;
|
||||
background-color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.country-pill {
|
||||
min-width: 68px;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
background-color: #F7F8FA;
|
||||
border-right: 1px solid #E3E7ED;
|
||||
}
|
||||
|
||||
.secondary-btn-text {
|
||||
color: var(--oc-text, #101828);
|
||||
.country-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.social-block {
|
||||
margin-top: var(--oc-space-24, 24px);
|
||||
.country-arrow {
|
||||
font-size: 10px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 验证码行内一体化组合 */
|
||||
.code-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 12px;
|
||||
background-color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
margin-bottom: var(--oc-space-12, 12px);
|
||||
.code-input {
|
||||
flex: 1;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.login-bottom-spacer {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.login-cta-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 12px var(--oc-space-16, 16px) var(--oc-space-24, 24px) var(--oc-space-16, 16px);
|
||||
background-color: var(--oc-bg, #FFFFFF);
|
||||
border-top: 1px solid var(--oc-border, #E3E7ED);
|
||||
}
|
||||
|
||||
.primary-cta-btn {
|
||||
height: 48px;
|
||||
background-color: var(--oc-primary, #0B5CFF);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
.inline-code-btn {
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border-left: 1px solid #E3E7ED;
|
||||
}
|
||||
|
||||
.inline-code-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0B5CFF;
|
||||
}
|
||||
|
||||
.inline-code-disabled .inline-code-text {
|
||||
color: #98A2B3;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.error-banner {
|
||||
padding: 8px 12px;
|
||||
background-color: #FEF3F2;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.error-banner-text {
|
||||
font-size: 12px;
|
||||
color: #F04438;
|
||||
}
|
||||
|
||||
/* 4. 单一主 CTA 按钮 */
|
||||
.primary-cta-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
background-color: #0B5CFF;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 18px;
|
||||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.primary-cta-btn-disabled {
|
||||
background-color: var(--oc-border, #E3E7ED);
|
||||
.primary-cta-btn:active {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.primary-cta-btn-text {
|
||||
color: var(--oc-bg, #FFFFFF);
|
||||
.primary-cta-disabled {
|
||||
background-color: #E3E7ED;
|
||||
}
|
||||
|
||||
.primary-cta-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-cta-btn-disabled .primary-cta-btn-text {
|
||||
color: var(--oc-text-2, #667085);
|
||||
.primary-cta-disabled .primary-cta-text {
|
||||
color: #98A2B3;
|
||||
}
|
||||
|
||||
/* 5. 社交快捷登录通道 */
|
||||
.social-divider {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: #E3E7ED;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: 12px;
|
||||
color: #98A2B3;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.social-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.social-capsule {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.social-capsule:active {
|
||||
background-color: #F7F8FA;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #4285F4;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.social-icon-apple {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.social-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
/* 6. 法律声明与版权 */
|
||||
.legal-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legal-text {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.legal-links-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0B5CFF;
|
||||
}
|
||||
|
||||
.legal-and {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.copyright-line {
|
||||
font-size: 11px;
|
||||
color: #98A2B3;
|
||||
}
|
||||
|
||||
/* RTL 适配 */
|
||||
.is-rtl .country-pill {
|
||||
border-right: none;
|
||||
border-left: 1px solid #E3E7ED;
|
||||
}
|
||||
|
||||
.is-rtl .inline-code-btn {
|
||||
border-left: none;
|
||||
border-right: 1px solid #E3E7ED;
|
||||
}
|
||||
|
||||
.is-rtl .social-icon,
|
||||
.is-rtl .social-icon-apple {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,8 @@ export type SocialProvider = 'google' | 'apple';
|
||||
|
||||
export type LoginCopyKey =
|
||||
| 'title'
|
||||
| 'brandTag'
|
||||
| 'subtitle'
|
||||
| 'desc'
|
||||
| 'tabPhone'
|
||||
| 'tabEmail'
|
||||
@@ -21,12 +23,18 @@ export type LoginCopyKey =
|
||||
| 'login'
|
||||
| 'magicLink'
|
||||
| 'emailPlaceholder'
|
||||
| 'orContinueWith'
|
||||
| 'google'
|
||||
| 'apple'
|
||||
| 'comingSoon'
|
||||
| 'phoneRequired'
|
||||
| 'emailRequired'
|
||||
| 'codeRequired'
|
||||
| 'termsNotice'
|
||||
| 'termsOfService'
|
||||
| 'privacyPolicy'
|
||||
| 'and'
|
||||
| 'countdownSuffix'
|
||||
| 'loginSuccess';
|
||||
|
||||
export type LoginState = {
|
||||
@@ -37,6 +45,7 @@ export type LoginState = {
|
||||
email: string;
|
||||
submitting: boolean;
|
||||
errorMessage: string;
|
||||
countdown: number;
|
||||
};
|
||||
|
||||
function isZhLocale(): boolean {
|
||||
@@ -46,7 +55,7 @@ function isZhLocale(): boolean {
|
||||
/**
|
||||
* 登录文案:能复用 locale.uts 的走词典,缺 key 时中英简单分支
|
||||
*/
|
||||
export function resolveLoginText(key: LoginCopyKey): string {
|
||||
export function resolveLoginText(key: LoginCopyKey | string): string {
|
||||
if (key === 'loginSuccess') {
|
||||
return getOmniText(getCurrentLocale(), 'common.success');
|
||||
}
|
||||
@@ -55,6 +64,12 @@ export function resolveLoginText(key: LoginCopyKey): string {
|
||||
if (key === 'title') {
|
||||
return zh ? '登录 OmniCard' : 'Sign in to OmniCard';
|
||||
}
|
||||
if (key === 'brandTag') {
|
||||
return zh ? '全球智能数字名片' : 'Global Digital Identity';
|
||||
}
|
||||
if (key === 'subtitle') {
|
||||
return zh ? '极简出海商务,连接全球商机' : 'Minimalist global commerce & identity';
|
||||
}
|
||||
if (key === 'tabPhone') {
|
||||
return zh ? '手机号' : 'Phone';
|
||||
}
|
||||
@@ -85,11 +100,14 @@ export function resolveLoginText(key: LoginCopyKey): string {
|
||||
if (key === 'emailPlaceholder') {
|
||||
return zh ? '工作邮箱' : 'Work email';
|
||||
}
|
||||
if (key === 'orContinueWith') {
|
||||
return zh ? '或通过以下方式继续' : 'Or continue with';
|
||||
}
|
||||
if (key === 'google') {
|
||||
return zh ? '使用 Google 继续' : 'Continue with Google';
|
||||
return zh ? 'Google' : 'Google';
|
||||
}
|
||||
if (key === 'apple') {
|
||||
return zh ? '使用 Apple 继续' : 'Continue with Apple';
|
||||
return zh ? 'Apple' : 'Apple';
|
||||
}
|
||||
if (key === 'comingSoon') {
|
||||
return zh ? '即将开放' : 'Coming soon';
|
||||
@@ -103,6 +121,21 @@ export function resolveLoginText(key: LoginCopyKey): string {
|
||||
if (key === 'codeRequired') {
|
||||
return zh ? '请填写验证码' : 'Enter the verification code';
|
||||
}
|
||||
if (key === 'termsNotice') {
|
||||
return zh ? '登录即代表您已同意' : 'By continuing, you agree to our';
|
||||
}
|
||||
if (key === 'termsOfService') {
|
||||
return zh ? '服务条款' : 'Terms of Service';
|
||||
}
|
||||
if (key === 'privacyPolicy') {
|
||||
return zh ? '隐私政策' : 'Privacy Policy';
|
||||
}
|
||||
if (key === 'and') {
|
||||
return zh ? '与' : 'and';
|
||||
}
|
||||
if (key === 'countdownSuffix') {
|
||||
return zh ? '秒后重发' : 's';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -113,21 +146,16 @@ function toastNone(title: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
function canNavigateBack(): boolean {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
return pages != null && pages.length > 1;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function finishAfterLogin(): void {
|
||||
if (canNavigateBack()) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
if (typeof uni.switchTab === 'function') {
|
||||
uni.switchTab({
|
||||
url: '/pages/owner/card/index',
|
||||
});
|
||||
} else if (typeof uni.reLaunch === 'function') {
|
||||
uni.reLaunch({
|
||||
url: '/pages/owner/card/index',
|
||||
});
|
||||
}
|
||||
toastNone(resolveLoginText('loginSuccess'));
|
||||
}
|
||||
|
||||
export function createLoginState(): LoginState {
|
||||
@@ -139,6 +167,7 @@ export function createLoginState(): LoginState {
|
||||
email: '',
|
||||
submitting: false,
|
||||
errorMessage: '',
|
||||
countdown: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +176,15 @@ export function switchLoginTab(state: LoginState, tab: LoginTab): void {
|
||||
state.errorMessage = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 倒计时推进函数
|
||||
*/
|
||||
export function tickCountdown(state: LoginState): void {
|
||||
if (state.countdown > 0) {
|
||||
state.countdown -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功路径:写入 token / 可选 tenantId,再返回上一页或 toast
|
||||
*/
|
||||
@@ -168,6 +206,10 @@ export function completeLogin(token: string, tenantId?: string): boolean {
|
||||
}
|
||||
|
||||
export function sendSmsCode(state: LoginState): boolean {
|
||||
if (state.countdown > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const phone = state.phone != null ? state.phone.trim() : '';
|
||||
if (phone === '') {
|
||||
state.errorMessage = resolveLoginText('phoneRequired');
|
||||
@@ -176,8 +218,9 @@ export function sendSmsCode(state: LoginState): boolean {
|
||||
}
|
||||
|
||||
state.errorMessage = '';
|
||||
state.countdown = 60;
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户