Files
jiapuapp/pages/auth/a01-entry.vue
T
2026-07-23 08:24:07 +08:00

852 lines
21 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.
<!-- 页面编号A-01用途APP 唯一登录入口承载密码验证码与微信登录入口 -->
<template>
<AuthPageShell class="auth-page login-page">
<view class="login-content">
<view class="login-heading">
<text class="login-title">登录家谱</text>
<image
class="title-divider"
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
mode="aspectFit"
/>
</view>
<view class="login-tabs" role="tablist" aria-label="登录方式">
<button
class="auth-plain-button login-tab login-tab--unavailable"
role="tab"
:aria-selected="activeLoginMethod === 'password'"
aria-disabled="true"
hover-class="tap-fade"
@click="switchLoginMethod('password')"
>密码登录</button
>
<button
class="auth-plain-button login-tab"
role="tab"
:aria-selected="activeLoginMethod === 'sms'"
:class="{ active: activeLoginMethod === 'sms' }"
hover-class="tap-fade"
@click="switchLoginMethod('sms')"
>验证码登录</button
>
</view>
<view class="form-content">
<view class="input-row">
<image
class="input-icon"
src="/static/assets/modules/auth/transparent/a01-icon-phone-v1.png"
mode="aspectFit"
/>
<input
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
placeholder="手机号"
aria-label="手机号"
placeholder-class="input-placeholder"
confirm-type="next"
/>
</view>
<view v-if="activeLoginMethod === 'password'" class="input-row">
<image
class="input-icon"
src="/static/assets/modules/auth/transparent/a01-icon-lock-v1.png"
mode="aspectFit"
/>
<input
v-model="password"
class="auth-input"
:password="!passwordVisible"
maxlength="32"
:disabled="submitting"
placeholder="密码"
aria-label="登录密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="submitLogin"
/>
<button
class="auth-plain-button password-toggle"
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
:aria-pressed="passwordVisible"
hover-class="tap-fade"
@click="togglePasswordVisibility"
>
<image
class="password-toggle__icon"
:src="
passwordVisible
? '/static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png'
: '/static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</button>
</view>
<view v-else class="input-row">
<image
class="input-icon input-icon--sms"
src="/static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png"
mode="aspectFit"
/>
<input
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="4"
:disabled="submitting"
placeholder="短信验证码"
aria-label="短信验证码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="submitLogin"
/>
<button
class="auth-plain-button get-code"
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
:disabled="sendingCode || submitting || cooldownSeconds > 0"
hover-class="tap-fade"
@click="prepareGetCode"
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
>
</view>
<view class="form-secondary-row">
<button
class="auth-plain-button forgot-password"
hover-class="tap-fade"
@click="prepareForgotPassword"
>忘记密码</button
>
</view>
</view>
<button
class="auth-plain-button login-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-pressed"
@click="submitLogin"
>
<image
class="button-skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="login-submit__copy">{{ submitting ? "登录中…" : "登录" }}</text>
</button>
<view class="other-login-divider">
<view class="divider-line" />
<image
class="divider-knot"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<text>其他登录方式</text>
<image
class="divider-knot divider-knot--right"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<view class="divider-line" />
</view>
<button
class="auth-plain-button wechat-login"
:disabled="submitting || sendingCode || tacVisible"
hover-class="button-pressed"
@click="prepareWechatLogin"
>
<image
class="button-skin"
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
mode="aspectFit"
/>
<view class="wechat-login__content">
<image
class="wechat-icon"
src="/static/assets/foundation/transparent/auth-wechat.png"
mode="aspectFit"
/>
<text>微信登录</text>
</view>
</button>
<view class="register-entry">
<text>还没有账号</text>
<button
class="auth-plain-button register-link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="prepareRegister"
>注册账号</button
>
</view>
<view
class="agreement-area"
:class="{ 'agreement-area--error': agreementError }"
>
<view class="agreement-row">
<button
class="auth-plain-button agreement-toggle"
role="checkbox"
:aria-checked="agreed"
:aria-label="agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'"
:disabled="submitting || sendingCode || tacVisible"
@click="toggleAgreement"
>
<image
class="agreement-icon"
:src="
agreed
? '/static/assets/modules/auth/transparent/a02-agreement-checked.png'
: '/static/assets/modules/auth/transparent/a02-agreement-unchecked.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<view class="agreement-copy">
<text>我已阅读并同意</text>
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>用户协议</button
>
<text></text>
<button class="auth-plain-button agreement-link" @click="prepareAgreement"
>隐私政策</button
>
</view>
</view>
<text v-if="agreementError" class="agreement-error" role="alert"
>请先阅读并同意用户协议与隐私政策</text
>
</view>
</view>
<template #overlay>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AuthPageShell from "@/components/AuthPageShell.vue";
import AppToast from "@/components/AppToast.vue";
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_TAC_SCENE,
PASSWORD_TAC_BLOCKED_MESSAGE,
createTacRenderContext,
isAuthPhone,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { runtimeConfig } from "@/utils/config.js";
import {
goRoot,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
const activeLoginMethod = ref("sms");
const passwordVisible = ref(false);
const phone = ref("");
const password = ref("");
const verificationCode = ref("");
const agreed = ref(false);
const agreementError = ref(false);
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const cooldownSeconds = ref(0);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
let feedbackTimer = null;
let cooldownTimer = null;
let tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
onUnload(() => {
pageActive = false;
authRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
if (cooldownTimer) clearInterval(cooldownTimer);
});
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const blockBusyAction = () => {
if (!sendingCode.value && !submitting.value) return false;
showFeedback("请求处理中,请稍候");
return true;
};
const switchLoginMethod = (method) => {
if (blockBusyAction()) return;
if (method === "password") {
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
return;
}
if (method === "sms") activeLoginMethod.value = method;
};
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value;
};
const toggleAgreement = () => {
agreed.value = !agreed.value;
if (agreed.value) agreementError.value = false;
};
const validatePhone = () => {
if (isAuthPhone(phone.value)) return true;
showFeedback("请输入正确的手机号");
return false;
};
const requireAgreement = () => {
if (agreed.value) return true;
agreementError.value = true;
return false;
};
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value,
submitting: submitting.value || sendingCode.value,
"close-transient": closeTac,
"block-submitting": () => {
cancelPendingRequest();
return true;
},
});
// A01 是认证根页:没有浮层时必须把系统返回交还 Android,只在行为验证浮层
// 可见时消费返回键,避免根页被守卫误拦而无法正常退出应用。
onBackPress((event) => {
if (!tacVisible.value && (submitting.value || sendingCode.value)) {
cancelPendingRequest();
return false;
}
if (!tacVisible.value) return false;
return handleBackPress(event, requestBack);
});
const startCooldown = () => {
cooldownSeconds.value = 60;
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = setInterval(() => {
cooldownSeconds.value -= 1;
if (cooldownSeconds.value <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
};
const prepareGetCode = async () => {
if (sendingCode.value || cooldownSeconds.value > 0) return;
if (!validatePhone() || !requireAgreement()) return;
sendingCode.value = true;
try {
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{ sceneCode, subject: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
const requirement = normalizeCaptchaRequirement(response, sceneCode);
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `a01-sms-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
sceneCode,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "安全验证暂不可用");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const completeTac = async (result) => {
const expectedContext = tacContext.value;
if (!expectedContext) return;
try {
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
closeTac();
sendingCode.value = true;
await appApi.sendSmsCode(
{
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
startCooldown();
showFeedback("验证码已发送");
} catch (error) {
closeTac();
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "验证码发送失败");
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const submitLogin = async () => {
if (submitting.value) return;
if (!validatePhone()) return;
if (activeLoginMethod.value === "password" && !password.value) {
showFeedback("请输入密码");
return;
}
if (
activeLoginMethod.value === "sms" &&
!/^\d{4}$/.test(verificationCode.value)
) {
showFeedback("请输入 4 位验证码");
return;
}
if (!requireAgreement()) return;
if (activeLoginMethod.value === "password") {
// 受保护源合同明确禁止密码登录携带 TAC 票据。仅在客户端先滑动仍可被
// 绕过,因此此入口必须失败关闭;短信登录已经具备 TAC→短信票据闭环。
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
return;
}
submitting.value = true;
try {
await appApi.loginWithSms(
{
phone: phone.value,
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
await goRoot("G01");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "登录失败,请稍后重试");
}
} finally {
if (pageActive) submitting.value = false;
}
};
const prepareForgotPassword = () => {
if (blockBusyAction()) return;
return openPage("A05", {}, "A01");
};
const prepareRegister = () => {
if (blockBusyAction()) return;
return openPage("A04", {}, "A01");
};
const prepareWechatLogin = () => {
if (!requireAgreement()) return;
showFeedback("微信登录功能准备中");
};
const prepareAgreement = () => showFeedback("协议页面准备中");
</script>
<style scoped lang="scss">
.login-content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
max-width: 480px;
min-width: 0;
margin: 0 auto;
padding: clamp(10px, 2.2vh, 20px) clamp(24px, 8.5vw, 44px);
}
.login-heading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50px;
}
.login-title {
color: #9f170f;
font-size: 58rpx;
font-weight: 700;
letter-spacing: 5rpx;
line-height: 1;
}
.title-divider {
flex: none;
width: 280rpx;
height: 46rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2);
}
.login-tabs {
display: flex;
align-items: flex-end;
min-height: var(--app-touch-min);
}
.login-tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #80684f;
font-size: 34rpx;
letter-spacing: 2rpx;
}
.login-tab:first-child::before {
content: none;
}
.login-tab:first-child {
border-right: 1rpx solid rgba(176, 120, 52, 0.58);
}
.login-tab.active {
background: linear-gradient(#ad160d, #ad160d) center bottom /
calc(100% - 116rpx) 5rpx no-repeat;
color: #a9160d;
font-weight: 700;
}
.login-tab.active::after {
content: none;
}
.form-content {
margin-top: 10rpx;
}
.input-row {
display: flex;
align-items: center;
box-sizing: border-box;
min-height: var(--app-touch-min);
padding-left: 12rpx;
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
}
.input-icon {
flex: 0 0 auto;
width: 64rpx;
height: 64rpx;
margin-right: 22rpx;
transform: translateX(-12rpx);
}
.input-icon--sms {
transform: translateX(-12rpx) scale(1.45);
}
.auth-input {
flex: 1;
min-width: 0;
min-height: var(--app-touch-min);
color: #493323;
font-size: 30rpx;
}
.input-placeholder {
color: #9f968d;
}
.password-toggle {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 88rpx;
height: var(--app-touch-min);
}
.password-toggle__icon {
width: 64rpx;
height: 64rpx;
}
.get-code {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
min-width: 142rpx;
min-height: var(--app-touch-min);
color: #a9160d;
font-size: 28rpx;
}
.login-tab--unavailable {
color: #9f968d;
}
.get-code--disabled {
color: #9f968d;
}
.form-secondary-row {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: var(--app-touch-min);
}
.forgot-password {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a9160d;
font-size: 28rpx;
}
.login-submit,
.wechat-login {
display: grid;
place-items: center;
}
.login-submit {
min-height: var(--app-touch-min);
height: var(--app-touch-min);
}
.button-skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
pointer-events: none;
}
.login-submit__copy {
z-index: 1;
grid-area: 1 / 1;
color: #fffaf1;
font-size: 36rpx;
letter-spacing: 5rpx;
}
.other-login-divider {
display: flex;
align-items: center;
min-height: 30px;
color: #9f6a27;
font-size: 28rpx;
white-space: nowrap;
}
.divider-line {
flex: 1;
height: 1rpx;
margin: 0 8rpx;
background: rgba(180, 113, 38, 0.64);
}
.divider-knot {
width: 17rpx;
height: 17rpx;
margin-right: 7rpx;
}
.divider-knot--right {
margin: 0 0 0 7rpx;
transform: scaleX(-1);
}
.wechat-login {
min-height: var(--app-touch-min);
height: var(--app-touch-min);
color: #493323;
font-size: 30rpx;
letter-spacing: 2rpx;
}
.wechat-login__content {
z-index: 1;
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
.wechat-icon {
width: 48rpx;
height: 48rpx;
margin-right: 14rpx;
transform: translateX(-8rpx) scale(1.22);
}
.register-entry {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
color: #493323;
font-size: 28rpx;
}
.register-link {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
margin-left: 8rpx;
color: #a9160d;
}
.agreement-area {
color: #58483c;
}
.agreement-row {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
font-size: 24rpx;
line-height: 32rpx;
}
.agreement-icon {
flex: 0 0 auto;
width: 34rpx;
height: 34rpx;
}
.agreement-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: var(--app-touch-min);
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin-right: 2rpx;
}
.agreement-copy {
display: flex;
flex: 0 1 auto;
align-items: center;
min-width: 0;
}
.agreement-link {
display: inline-flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a9160d;
}
.agreement-error {
display: block;
color: #b42318;
font-size: 20rpx;
line-height: 30rpx;
text-align: center;
}
.agreement-area--error {
animation: agreement-shake 240ms ease-out;
}
@keyframes agreement-shake {
0%,
100% {
transform: translateX(0);
}
33% {
transform: translateX(-8rpx);
}
66% {
transform: translateX(8rpx);
}
}
.tap-fade {
opacity: 0.62;
}
.button-pressed {
opacity: 0.82;
}
</style>