Files
jiapuapp/pages/auth/a04-register.vue
T
2026-07-27 06:50:23 +08:00

823 lines
23 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-04用途注册账号与本页服务协议确认 -->
<template>
<AuthPageShell class="auth-page register-page">
<view class="register-content">
<view class="page-heading">
<button
class="auth-plain-button back-button"
aria-label="返回登录"
hover-class="tap-fade"
@click="requestBack"
>
<image
class="back-button__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<text class="page-title">注册账号</text>
<text class="page-subtitle">创建属于你的家谱账号</text>
<image
class="register-divider"
src="/static/assets/modules/auth/transparent/a01-vnext-divider-v1.png"
mode="aspectFit"
/>
</view>
<view class="form-content">
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<label class="input-label" for="a04-phone"><text class="required-mark">*</text>手机号</label>
<input
id="a04-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="fieldErrors.phone ? 'a04-phone-error' : undefined"
@input="handlePhoneInput"
/>
</view>
<text v-if="fieldErrors.phone" id="a04-phone-error" class="field-error" role="alert">{{
fieldErrors.phone
}}</text>
</view>
<view class="field-block">
<view class="input-row">
<label class="input-label" for="a04-nickname">昵称</label>
<input
id="a04-nickname"
v-model.trim="nickName"
class="auth-input"
:disabled="submitting || registrationCommitted"
placeholder="请输入昵称"
placeholder-class="placeholder"
/>
</view>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<label class="input-label" for="a04-verification-code"><text class="required-mark">*</text>验证码</label>
<input
id="a04-verification-code"
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="4"
:disabled="submitting || registrationCommitted"
placeholder="请输入验证码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.verificationCode)"
:aria-describedby="fieldErrors.verificationCode ? 'a04-verification-code-error' : undefined"
@input="clearFieldError('verificationCode')"
/>
<button
class="auth-plain-button get-code"
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
:disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"
hover-class="tap-fade"
@click="prepareGetCode"
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
>
</view>
<text v-if="fieldErrors.verificationCode" id="a04-verification-code-error" class="field-error" role="alert">{{
fieldErrors.verificationCode
}}</text>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<label class="input-label" for="a04-password"><text class="required-mark">*</text>设置密码</label>
<input
id="a04-password"
v-model="password"
class="auth-input"
password
maxlength="32"
:disabled="submitting || registrationCommitted"
placeholder="请设置登录密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.password)"
:aria-describedby="fieldErrors.password ? 'a04-password-error' : undefined"
@input="clearFieldError('password')"
/>
</view>
<text v-if="fieldErrors.password" id="a04-password-error" class="field-error" role="alert">{{
fieldErrors.password
}}</text>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<label class="input-label" for="a04-confirm-password"><text class="required-mark">*</text>确认密码</label>
<input
id="a04-confirm-password"
v-model="confirmPassword"
class="auth-input"
password
maxlength="32"
:disabled="submitting || registrationCommitted"
placeholder="请再次输入密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
:aria-describedby="fieldErrors.confirmPassword ? 'a04-confirm-password-error' : undefined"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text v-if="fieldErrors.confirmPassword" id="a04-confirm-password-error" class="field-error" role="alert">{{
fieldErrors.confirmPassword
}}</text>
</view>
</view>
<button
class="auth-plain-button register-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-hover"
@click="submitRegister"
>
<image
class="register-submit__skin"
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text class="register-submit__content">{{
submitting
? registrationCommitted
? "正在进入…"
: "注册中…"
: registrationCommitted
? "进入家谱"
: "注册账号"
}}</text>
</button>
<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="sendingCode || submitting || tacVisible || registrationCommitted"
@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 class="login-entry">
<text>已有账号</text>
<button
class="auth-plain-button login-entry__link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="requestBack"
>登录</button
>
</view>
</view>
<template #overlay>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppDialog
:visible="discardVisible"
title="放弃注册?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/AuthPageShell.vue";
import TacVerification from "@/components/TacVerification.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import {
AUTH_VERIFICATION_OPERATION,
createTacRenderContext,
isAuthPhone,
isSmsDeliveryOutcomeUnknown,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.js";
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
import { runtimeConfig } from "@/utils/config.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/validation.js";
const phone = ref("");
const nickName = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const agreed = ref(false);
const agreementError = ref(false);
const fieldErrors = ref({
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const submitting = ref(false);
const registrationCommitted = ref(false);
const cooldownSeconds = ref(0);
const sentPhone = ref("");
const isDirty = computed(() =>
!registrationCommitted.value &&
Boolean(
phone.value ||
nickName.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
agreed.value,
),
);
let feedbackTimer = null;
let tacSequence = 0;
let pageActive = true;
const registrationNavigationFailure =
"注册已完成,但暂时无法进入家谱,请再次点击进入";
const authRequestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
const requestBack = () =>
registrationCommitted.value
? enterAuthenticatedRoot()
: runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
submitting: submitting.value || sendingCode.value,
dirty: isDirty.value,
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
cancelPendingRequest();
return requestBack();
},
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
authRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
smsCooldown.dispose();
discardConfirmation.dispose();
});
onShow(() => smsCooldown.sync());
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const enterAuthenticatedRoot = async () => {
if (!pageActive) return false;
submitting.value = true;
try {
const opened = await goRoot("G01");
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
return true;
} catch {
if (pageActive) showFeedback(registrationNavigationFailure);
return false;
} finally {
if (pageActive) submitting.value = false;
}
};
const prepareAgreement = () => showFeedback("协议页面准备中");
const toggleAgreement = () => {
if (sendingCode.value || submitting.value || registrationCommitted.value)
return;
agreed.value = !agreed.value;
if (agreed.value) agreementError.value = false;
};
const clearFieldError = (field) => {
fieldErrors.value[field] = "";
};
const handlePhoneInput = () => {
clearFieldError("phone");
if (!sentPhone.value || phone.value === sentPhone.value) return;
verificationCode.value = "";
sentPhone.value = "";
};
const prepareGetCode = async () => {
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
fieldErrors.value.phone = "请输入正确手机号";
return;
}
fieldErrors.value.phone = "";
if (!agreed.value) {
agreementError.value = true;
return;
}
sendingCode.value = true;
try {
const operationCode = AUTH_VERIFICATION_OPERATION.REGISTER;
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{ operationCode, subject: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
const requirement = normalizeCaptchaRequirement(response);
if (!requirement.required) {
await appApi.sendSmsCode(
{ operationCode, phone: requestedPhone },
{ requestController: authRequestController },
);
if (!pageActive) return;
sentPhone.value = requestedPhone;
smsCooldown.start();
showFeedback("验证码已发送");
return;
}
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `a04-register-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
operationCode,
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(
{
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
sentPhone.value = expectedContext.subject;
smsCooldown.start();
showFeedback("验证码已发送");
} catch (error) {
closeTac();
if (pageActive) {
if (isSmsDeliveryOutcomeUnknown(error)) {
sentPhone.value = expectedContext.subject;
smsCooldown.start();
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
} else if (!isRequestCancelled(error)) {
showFeedback(error.message || "验证码发送失败");
}
}
} finally {
if (pageActive) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const validateForm = () => {
const nextErrors = {
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (sentPhone.value !== phone.value)
nextErrors.verificationCode = "请先获取当前手机号的验证码";
else if (!/^\d{4}$/.test(verificationCode.value))
nextErrors.verificationCode = "请输入 4 位验证码";
const passwordResult = validatePassword(password.value);
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入密码";
else if (password.value !== confirmPassword.value)
nextErrors.confirmPassword = "两次输入的密码不一致";
fieldErrors.value = nextErrors;
return !Object.values(nextErrors).some(Boolean);
};
const submitRegister = async () => {
if (submitting.value || sendingCode.value) return;
if (registrationCommitted.value) return enterAuthenticatedRoot();
const formValid = validateForm();
if (!agreed.value) agreementError.value = true;
if (!formValid || !agreed.value) return;
submitting.value = true;
try {
await appApi.registerWithPassword(
{
phone: phone.value,
nickName: nickName.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
registrationCommitted.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showFeedback(error.message || "注册失败,请稍后重试");
}
return;
} finally {
if (pageActive) submitting.value = false;
}
if (!pageActive) return;
await enterAuthenticatedRoot();
};
</script>
<style scoped lang="scss">
.register-content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-start;
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);
}
.page-heading {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
justify-items: center;
min-height: 154rpx;
}
.back-button {
display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center;
justify-content: center;
width: var(--app-touch-min);
height: var(--app-touch-min);
margin-top: 10rpx;
}
.back-button__icon {
width: 42rpx;
height: 42rpx;
transform: scaleX(-1);
}
.page-title {
grid-column: 1;
grid-row: 1;
color: #9f170f;
font-size: 58rpx;
font-weight: 700;
letter-spacing: 7rpx;
}
.page-subtitle {
grid-column: 1;
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: 25rpx;
letter-spacing: 2rpx;
}
.register-divider {
grid-column: 1;
grid-row: 3;
width: 300rpx;
height: 50rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2);
}
.form-content {
margin-top: 12rpx;
}
.field-block {
min-height: 116rpx;
}
.input-row {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
}
.input-label {
flex: 0 0 152rpx;
color: #674b35;
font-size: 30rpx;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: #b42318;
font-size: 26rpx;
line-height: 1;
transform: translateY(-3rpx);
}
.auth-input {
flex: 1;
min-width: 0;
min-height: var(--app-touch-min);
color: #493323;
font-size: 30rpx;
}
.get-code {
display: flex;
flex: 0 0 176rpx;
box-sizing: border-box;
align-items: center;
justify-content: flex-end;
padding: 0 8rpx;
border-left: 1rpx solid #d7bd94;
background: transparent !important;
color: #a7160c;
font-size: 26rpx;
white-space: nowrap;
}
.get-code--disabled {
color: #9f968d;
}
.placeholder {
color: #9f968d;
}
.field-error,
.agreement-error {
display: block;
color: #b42318;
font-size: 21rpx;
line-height: 32rpx;
}
.field-error {
padding-top: 4rpx;
text-align: right;
}
.field-block--error .input-row {
border-bottom-color: #b42318;
}
.register-submit {
display: grid;
place-items: center;
min-height: var(--app-touch-min);
margin-top: 36rpx;
}
.register-submit__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.register-submit__content {
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: 36rpx;
letter-spacing: 6rpx;
}
.agreement-area {
min-height: 118rpx;
margin-top: 18rpx;
}
.agreement-row {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
color: #493323;
font-size: 24rpx;
line-height: 30rpx;
}
.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;
align-items: center;
}
.agreement-link {
display: inline-flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a7160c;
}
.agreement-error {
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);
}
}
.login-entry {
display: flex;
align-items: center;
justify-content: center;
min-height: 82rpx;
margin-top: auto;
color: #493323;
font-size: 28rpx;
}
.login-entry__link {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
margin-left: 8rpx;
color: #a7160c;
}
.tap-fade {
opacity: 0.62;
}
.button-hover {
opacity: 0.84;
}
</style>