Files
prop-ai-hr/mobile-uni/src/pages/auth/login/index.vue
T

748 lines
17 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>
<view class="page login-page">
<view class="login-shell">
<view class="brand-lockup">
<image class="login-logo" src="/static/brand-logo.png" mode="aspectFit" />
<view class="brand-copy">
<text class="brand-name">帮道</text>
<text class="brand-divider">·</text>
<text class="login-eyebrow">数字师傅</text>
</view>
</view>
<view class="mentor-block">
<image class="mentor-image" src="/static/mentor-female-bust.png" mode="aspectFit" />
<view class="mentor-bubble spring-pop">
<text class="mentor-bubble-text">手机号登录,立刻上手</text>
</view>
</view>
<view class="panel login-card">
<uni-forms
ref="loginForm"
class="login-form"
:model="formData"
:rules="formRules"
label-position="top"
validate-trigger="submit"
err-show-type="undertext"
:border="false"
>
<uni-forms-item label="手机号" name="phone">
<view class="field-shell">
<uni-icons class="field-icon" type="phone" size="22" color="#a7adb8" />
<uni-easyinput
v-model="formData.phone"
aria-label="手机号"
type="tel"
maxlength="11"
:clearable="false"
primary-color="#e60012"
@input="onPhoneInput"
placeholder="请输入手机号"
placeholder-style="color: #b5b8c1"
:styles="inputStyles"
/>
</view>
</uni-forms-item>
<uni-forms-item v-if="smsVerificationEnabled" label="验证码" name="smsCode">
<view class="code-row">
<view class="field-shell code-shell">
<uni-icons class="field-icon" type="locked" size="22" color="#a7adb8" />
<uni-easyinput
v-model="formData.smsCode"
class="code-input"
aria-label="验证码"
type="tel"
maxlength="6"
:clearable="false"
primary-color="#e60012"
@input="onSmsCodeInput"
placeholder="请输入验证码"
placeholder-style="color: #b5b8c1"
:styles="inputStyles"
/>
</view>
<button
class="secondary-button code-button"
:disabled="sending || countdown > 0"
@click="sendCode"
>
{{ sending ? '发送中' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码' }}
</button>
</view>
</uni-forms-item>
</uni-forms>
<view v-if="smsVerificationEnabled && showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
<uni-icons type="info-filled" size="14" color="#d97706" />
<text class="dev-hint-text">测试验证码: {{ devSmsHintValue }}</text>
<text class="dev-fill-btn">快捷填入</text>
</view>
<button class="primary-button login-submit" :disabled="loggingIn" @click="login">
{{ loggingIn ? '登录中…' : '登录' }}
</button>
<uni-notice-bar
v-if="message"
:text="message"
:background-color="message === '验证码已发送' ? '#edf7f0' : '#fff2f3'"
:color="message === '验证码已发送' ? '#168243' : '#bd0010'"
show-icon
/>
</view>
<view class="agreement-row">
<view
class="agreement-consent"
role="checkbox"
:aria-checked="agreed"
@click="agreed = !agreed"
>
<view class="checkbox-box" :class="{ checked: agreed }">
<uni-icons v-if="agreed" type="checkmarkempty" size="12" color="#ffffff" />
</view>
<text class="agreement-text">已阅读并同意</text>
</view>
<text class="agreement-link" @click.stop="openLegalDocument('terms')">《服务协议》</text>
<text class="agreement-text">与</text>
<text class="agreement-link" @click.stop="openLegalDocument('privacy')">《隐私政策》</text>
</view>
<text v-if="!legalLinksReady" class="agreement-error">
协议地址尚未完成法务配置,当前暂停验证码发送与登录。
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref, onUnmounted } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { LoginResult } from '@/types/api';
import { apiRequest } from '@/services/api';
import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth } from '@/services/auth';
import {
legalDocumentUrl,
legalLinksReady,
type LegalDocumentKind
} from '@/services/legal';
import { resetPageScroll } from '@/services/navigation';
import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position';
const stored = getAuth();
const loginForm = ref();
const smsVerificationEnabled = !import.meta.env.DEV
|| import.meta.env.VITE_SMS_VERIFICATION_ENABLED === 'true';
const formData = reactive({
phone: stored.phone || uni.getStorageSync(phoneKey) || '',
smsCode: ''
});
const formRules = {
phone: {
rules: [
{ required: true, errorMessage: '请输入手机号' },
{ pattern: '^1[3-9]\\d{9}$', errorMessage: '请输入正确手机号' }
]
},
smsCode: {
rules: smsVerificationEnabled ? [
{ required: true, errorMessage: '请输入短信验证码' },
{ pattern: '^\\d{4,6}$', errorMessage: '验证码应为 4 至 6 位数字' }
] : []
}
};
const sending = ref(false);
const loggingIn = ref(false);
const countdown = ref(0);
const agreed = ref(false);
const message = ref('');
const devSmsHintValue = import.meta.env.DEV
? String(import.meta.env.VITE_DEV_SMS_HINT_VALUE || '').replace(/\D/g, '').slice(0, 6)
: '';
const showDevSmsHint = import.meta.env.DEV
&& import.meta.env.VITE_DEV_SMS_HINT_ENABLED === 'true'
&& /^\d{4,6}$/.test(devSmsHintValue);
let timer: ReturnType<typeof setInterval> | null = null;
const inputStyles = {
color: '#25282d',
borderColor: 'transparent',
backgroundColor: 'transparent'
};
const stopTimer = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const startCountdown = () => {
stopTimer();
countdown.value = 60;
timer = setInterval(() => {
if (countdown.value > 1) {
countdown.value -= 1;
} else {
countdown.value = 0;
stopTimer();
}
}, 1000);
};
const fillDevCredentials = () => {
if (!showDevSmsHint) return;
formData.smsCode = devSmsHintValue;
message.value = '已自动填入本地测试验证码';
};
const normalizeDigits = (value: string | number, maxLength: number) =>
String(value || '')
.replace(/\D/g, '')
.slice(0, maxLength);
const onPhoneInput = (value: string | number) => {
formData.phone = normalizeDigits(value, 11);
};
const onSmsCodeInput = (value: string | number) => {
formData.smsCode = normalizeDigits(value, 6);
};
const ensureLegalConsent = () => {
if (!legalLinksReady) {
message.value = '服务协议和隐私政策地址尚未配置,暂不能发送验证码或登录';
return false;
}
if (!agreed.value) {
message.value = '请先阅读并勾选同意《服务协议》与《隐私政策》';
return false;
}
return true;
};
const openLegalDocument = (kind: LegalDocumentKind) => {
const url = legalDocumentUrl(kind);
if (!url) {
message.value = '协议地址尚未完成法务配置';
return;
}
if (typeof plus !== 'undefined') {
plus.runtime.openURL(url);
return;
}
if (typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener,noreferrer');
}
};
const goAfterLogin = async () => {
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
goToMobileTarget(await nextUserRouteAfterLogin(redirect));
};
const resumeAfterLogin = async () => {
try {
await goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '组织身份加载失败,请重试';
}
};
const sendCode = async () => {
if (sending.value || countdown.value > 0) return;
if (!ensureLegalConsent()) return;
try {
await loginForm.value?.validateField(['phone']);
} catch {
return;
}
const value = formData.phone.trim();
sending.value = true;
message.value = '';
try {
await apiRequest<void>({
url: `/resource/sms/code?phonenumber=${encodeURIComponent(value)}`,
method: 'GET',
auth: false
});
message.value = '验证码已发送';
startCountdown();
} catch (error) {
message.value = error instanceof Error ? error.message : '验证码发送失败';
} finally {
sending.value = false;
}
};
const login = async () => {
if (loggingIn.value) return;
if (!ensureLegalConsent()) return;
try {
await loginForm.value?.validate();
} catch {
return;
}
const value = formData.phone.trim();
loggingIn.value = true;
message.value = '';
try {
const result = await apiRequest<LoginResult>({
url: '/auth/mobile/sms-login',
method: 'POST',
data: {
phonenumber: value,
smsCode: formData.smsCode.trim()
},
auth: false
});
if (!result.access_token || !result.client_id) {
throw new Error('登录响应缺少认证信息');
}
saveAuth(result, value, '000000');
await goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '登录失败';
} finally {
loggingIn.value = false;
}
};
onShow(() => {
resetPageScroll();
if (isLoggedIn()) {
void resumeAfterLogin();
}
});
onHide(() => {
stopTimer();
});
onUnload(() => {
stopTimer();
});
onUnmounted(() => {
stopTimer();
});
</script>
<style scoped>
.login-page {
min-height: 100vh;
padding: 0;
background:
radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 248, 251, 0.9) 38%, rgba(239, 242, 247, 0.78) 100%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
.login-shell {
min-height: 100vh;
padding: 36px 24px 28px;
overflow: hidden;
}
.login-shell::before,
.login-shell::after {
position: fixed;
inset: auto;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
filter: blur(10px);
content: '';
pointer-events: none;
}
.login-shell::before {
top: 64px;
right: -46px;
}
.login-shell::after {
bottom: 120px;
left: -72px;
}
.brand-lockup {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 720px;
margin: 0 auto 22px;
}
.login-logo {
width: 34px;
height: 34px;
flex: none;
}
.brand-copy {
display: flex;
align-items: center;
gap: 8px;
}
.brand-name,
.brand-divider,
.login-eyebrow {
display: inline-flex;
align-items: center;
color: #34373c;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.brand-divider {
color: #8d9097;
font-weight: 400;
}
.login-eyebrow {
color: #e60012;
}
.mentor-block {
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 720px;
margin: 0 auto 18px;
padding: 0 6px;
}
.mentor-image {
width: 128px;
height: 130px;
flex: none;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.08));
}
.mentor-bubble {
position: relative;
margin-top: 20px;
padding: 12px 16px;
border: 1px solid rgba(225, 227, 232, 0.9);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(29, 31, 35, 0.08);
}
.mentor-bubble.spring-pop {
animation: springPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes springPop {
0% { transform: scale(0.85); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.mentor-bubble::before {
position: absolute;
top: 20px;
left: -6px;
width: 10px;
height: 10px;
border-bottom: 1px solid rgba(225, 227, 232, 0.9);
border-left: 1px solid rgba(225, 227, 232, 0.9);
background: #ffffff;
content: '';
transform: rotate(45deg);
}
.mentor-bubble-text {
color: #25282d;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.login-card {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
max-width: 720px;
margin: 0 auto;
padding: 24px 20px 24px;
border: 1px solid rgba(225, 227, 232, 0.95);
border-top: 4px solid #e60012;
border-radius: 20px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12px 32px rgba(29, 31, 35, 0.08), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
.login-card::before {
position: absolute;
inset: 0;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 26%);
content: '';
pointer-events: none;
}
.login-card > * {
position: relative;
z-index: 1;
}
.login-form {
margin-top: 0;
}
.login-form :deep(.uni-forms-item) {
margin-bottom: 18px;
}
.login-form :deep(.uni-forms-item:last-child) {
margin-bottom: 4px;
}
.login-form :deep(.uni-forms-item__label) {
padding-bottom: 6px;
color: #20242b;
font-size: 15px;
font-weight: 700;
}
.login-form :deep(.uni-forms-item__error) {
margin-top: 6px;
font-size: 12px;
}
.field-shell {
display: flex;
align-items: center;
min-height: 52px;
padding: 0 14px;
border: 1px solid #d8dce3;
border-radius: 12px;
background: #ffffff;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.field-shell:focus-within {
border-color: #e60012;
box-shadow: 0 0 0 3px rgba(230, 0, 18, 0.12);
}
.field-shell :deep(.uni-easyinput) {
flex: 1;
min-width: 0;
}
.field-shell :deep(.uni-easyinput__content) {
min-height: auto;
border: none !important;
border-radius: 0;
background: transparent !important;
}
.field-shell :deep(.uni-easyinput__content-input) {
height: 28px;
appearance: none;
-webkit-appearance: none;
background: transparent !important;
color: #25282d;
font-size: 15px;
line-height: 28px;
}
.field-shell :deep(.uni-easyinput__placeholder-class) {
color: #b5b8c1;
font-size: 14px;
}
.field-icon {
margin-right: 10px;
flex: none;
}
.code-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.code-shell {
flex: 1;
min-width: 0;
padding-right: 8px;
padding-left: 12px;
}
.code-button {
display: flex;
align-items: center;
justify-content: center;
width: 116px;
min-width: 116px;
height: 52px;
flex: 0 0 116px;
padding: 0 6px;
border: 1px solid #e60012;
border-radius: 12px;
background: #ffffff;
color: #e60012;
font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(230, 0, 18, 0.08);
transition: transform 0.15s ease, background 0.15s ease;
}
.code-button:active {
transform: scale(0.96);
background: #fff8f8;
}
.code-button:disabled {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
box-shadow: none;
}
.dev-hint-pill {
display: inline-flex;
align-items: center;
align-self: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
background: #fffdf5;
border: 1px dashed #f59e0b;
cursor: pointer;
transition: transform 0.15s ease, background 0.15s ease;
}
.dev-hint-pill:active {
transform: scale(0.96);
background: #fff8e6;
}
.dev-hint-text {
color: #b45309;
font-size: 12px;
font-weight: 600;
}
.dev-fill-btn {
color: var(--employee-primary, #e60012);
font-size: 12px;
font-weight: 800;
text-decoration: underline;
}
.login-submit {
display: flex;
width: 100%;
min-height: 54px;
margin-top: 4px;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0 20px;
border-radius: 14px;
background: linear-gradient(135deg, #ff4d4f 0%, #e60012 100%);
color: #ffffff;
font-size: 20px;
font-weight: 800;
letter-spacing: 2px;
box-shadow: 0 8px 20px rgba(230, 0, 18, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.24);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.login-submit:active {
transform: scale(0.97);
box-shadow: 0 4px 10px rgba(230, 0, 18, 0.2);
}
.login-card :deep(.uni-noticebar) {
margin: 0;
border-radius: 10px;
}
.agreement-row {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
max-width: 720px;
margin: 18px auto 0;
flex-wrap: wrap;
}
.agreement-consent {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1.5px solid #bdc1c9;
border-radius: 5px;
background: #ffffff;
transition: all 0.15s ease;
flex: none;
}
.checkbox-box.checked {
border-color: var(--employee-primary, #e60012);
background: var(--employee-primary, #e60012);
}
.agreement-text {
color: #8c9098;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.agreement-link {
color: var(--employee-primary, #e60012);
font-size: 12px;
font-weight: 650;
line-height: 1.4;
cursor: pointer;
}
.agreement-error {
display: block;
max-width: 720px;
margin: 8px auto 0;
color: #b45309;
font-size: 11px;
line-height: 1.5;
text-align: center;
}
@media (max-width: 375px) {
.login-shell {
padding-right: 20px;
padding-left: 20px;
}
.code-button {
width: 104px;
min-width: 104px;
flex-basis: 104px;
}
}
</style>