文件
OmniCard/mpuniapp/pages/common/login.uvue
wikiboss 6c3d5d323a refactor(arch): 落地平台架构演进第一二阶段整改,消除OOM与越权风险并支持高并发缓冲与AI多租户BYOK
- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId
- [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患
- [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建
- [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata
- [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘
- [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动
- [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范
- [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
2026-09-21 11:14:10 +08:00

828 行
19 KiB
Plaintext

<template>
<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-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="segment-item"
:class="{ 'segment-active': state.tab === 'password' }"
@click="handleSwitchTab('password')"
>
<text class="segment-text">{{ t('tabPassword') }}</text>
</view>
<view
class="segment-item"
:class="{ 'segment-active': state.tab === 'phone' }"
@click="handleSwitchTab('phone')"
>
<text class="segment-text">{{ t('tabPhone') }}</text>
</view>
<view
class="segment-item"
:class="{ 'segment-active': state.tab === 'email' }"
@click="handleSwitchTab('email')"
>
<text class="segment-text">{{ t('tabEmail') }}</text>
</view>
</view>
<!-- 3. 表单主体 -->
<!-- 3.1 账号密码真实登录面板 (便于测试与前后端对齐) -->
<view v-if="state.tab === 'password'" class="form-section">
<view class="form-field">
<text class="field-label">{{ t('usernamePlaceholder') }}</text>
<input
class="input-control text-input"
type="text"
v-model="state.username"
:placeholder="t('usernamePlaceholder')"
:disabled="state.submitting"
/>
</view>
<view class="form-field">
<text class="field-label">{{ t('passwordPlaceholder') }}</text>
<input
class="input-control text-input"
type="password"
v-model="state.password"
:placeholder="t('passwordPlaceholder')"
:disabled="state.submitting"
/>
</view>
<view class="test-account-badge" @click="handleAutofillTest">
<text class="test-account-text">{{ t('testAccountTip') }}</text>
</view>
</view>
<!-- 3.2 国际手机号验证码面板 -->
<view v-else-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="input-control phone-input"
type="tel"
v-model="state.phone"
:placeholder="t('phonePlaceholder')"
:disabled="state.submitting"
/>
</view>
</view>
<view class="form-field">
<text class="field-label">{{ t('smsCode') }}</text>
<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>
<!-- 3.3 邮箱 MagicLink 面板 -->
<view v-else class="form-section">
<view class="form-field">
<text class="field-label">{{ t('tabEmail') }}</text>
<input
class="input-control text-input"
type="text"
v-model="state.email"
:placeholder="t('emailPlaceholder')"
:disabled="state.submitting"
/>
</view>
</view>
<!-- 错误提示 -->
<view v-if="state.errorMessage" class="error-banner">
<text class="error-banner-text">{{ state.errorMessage }}</text>
</view>
<!-- 4. 单一主 CTA 按钮 (严格符合 AGENTS.md 规范) -->
<button
class="primary-cta-btn"
:class="{ 'primary-cta-disabled': state.submitting }"
role="button"
aria-label="登录"
: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>
</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,
submitPasswordLogin,
submitPhoneLogin,
sendMagicLink,
sendSmsCode,
tickCountdown,
tapSocialLogin,
resolveLoginText,
fillTestAccount,
LoginCopyKey,
LoginTab,
SocialProvider,
} from './useLogin.uts';
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-space-8': utsSpacing.space8,
'--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());
const isRtlMode = computed((): boolean => {
return isRtlLocale(currentLang.value);
});
function t(key: LoginCopyKey | string): string {
return resolveLoginText(key, currentLang.value);
}
const codeButtonText = computed((): string => {
if (state.countdown > 0) {
return `${state.countdown}${t('countdownSuffix')}`;
}
return t('sendCode');
});
const primaryButtonText = computed((): string => {
if (state.tab === 'password') {
return state.submitting ? t('loading') : t('login');
}
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.reLaunch({
url: '/pages/owner/hall/index',
});
},
});
} catch (_e) {
uni.reLaunch({
url: '/pages/owner/hall/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 {
const sent = sendSmsCode(state);
if (sent) {
runTimer();
}
}
function handleAutofillTest(): void {
fillTestAccount(state);
}
function handlePrimarySubmit(): void {
if (state.tab === 'password') {
submitPasswordLogin(state);
} else 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-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;
width: 100%;
display: flex;
flex-direction: column;
}
.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: 24px 22px;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.04);
box-sizing: border-box;
display: flex;
flex-direction: column;
}
/* 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: #101828;
letter-spacing: -0.3px;
line-height: 1.25;
margin-bottom: 6px;
}
.brand-tag-capsule {
display: inline-flex;
padding: 2px 8px;
background-color: #EAF1FF;
border-radius: 6px;
margin-bottom: 6px;
}
.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;
background-color: #F2F4F7;
border-radius: 10px;
padding: 3px;
margin-bottom: 18px;
}
.segment-item {
flex: 1;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: transparent;
transition: background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.segment-active {
background-color: #FFFFFF;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
}
.segment-text {
font-size: 14px;
font-weight: 500;
color: #667085;
}
.segment-active .segment-text {
color: #101828;
font-weight: 600;
}
/* 3. 表单结构 */
.form-section {
display: flex;
flex-direction: column;
}
.form-field {
display: flex;
flex-direction: column;
margin-bottom: 14px;
}
.field-label {
font-size: 13px;
font-weight: 600;
color: #101828;
margin-bottom: 6px;
}
.input-control,
.field-input {
height: 44px;
font-size: 16px;
color: #101828;
box-sizing: border-box;
}
.text-input {
width: 100%;
padding: 0 14px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 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;
background-color: #F7F8FA;
border-right: 1px solid #E3E7ED;
}
.country-text {
font-size: 14px;
font-weight: 600;
color: #101828;
margin-right: 4px;
}
.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: row;
align-items: center;
border: 1px solid #E3E7ED;
border-radius: 12px;
background-color: #FFFFFF;
overflow: hidden;
}
.code-input {
flex: 1;
padding: 0 14px;
border: none;
background-color: transparent;
}
.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;
}
/* 测试账号一键填充辅助胶囊 */
.test-account-badge {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 6px 10px;
background-color: #F7F8FA;
border: 1px dashed #D0D5DD;
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.test-account-badge:active {
background-color: #EAF1FF;
border-color: #0B5CFF;
}
.test-account-text {
font-size: 12px;
color: #0B5CFF;
font-weight: 500;
}
/* 错误提示 */
.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;
line-height: 44px;
min-height: 44px;
background-color: #0B5CFF;
color: #FFFFFF;
font-size: 15px;
font-weight: 600;
text-align: center;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
border: none;
margin-top: 6px;
margin-bottom: 18px;
cursor: pointer;
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-disabled {
background-color: #E3E7ED;
cursor: not-allowed;
}
.primary-cta-text {
color: #FFFFFF;
font-size: 15px;
font-weight: 600;
}
.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>