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

733 lines
20 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">手机号</label>
<input
id="a04-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="fieldErrors.phone ? 'a04-phone-error' : undefined"
@input="clearFieldError('phone')"
/>
</view>
<text v-if="fieldErrors.phone" id="a04-phone-error" class="field-error" role="alert">{{
fieldErrors.phone
}}</text>
</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">验证码</label>
<input
id="a04-verification-code"
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="4"
:disabled="submitting"
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"
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">设置密码</label>
<input
id="a04-password"
v-model="password"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
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">确认密码</label>
<input
id="a04-confirm-password"
v-model="confirmPassword"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
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 ? "注册中…" : "注册账号" }}</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"
@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, 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_TAC_SCENE,
createTacRenderContext,
isAuthPhone,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth-verification.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 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 cooldownSeconds = ref(0);
const isDirty = computed(() =>
Boolean(
phone.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
agreed.value,
),
);
let feedbackTimer = null;
let cooldownTimer = null;
let tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
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 = () =>
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);
if (cooldownTimer) clearInterval(cooldownTimer);
discardConfirmation.dispose();
});
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const prepareAgreement = () => showFeedback("协议页面准备中");
const toggleAgreement = () => {
if (sendingCode.value || submitting.value) return;
agreed.value = !agreed.value;
if (agreed.value) agreementError.value = false;
};
const clearFieldError = (field) => {
fieldErrors.value[field] = "";
};
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 || 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 sceneCode = AUTH_TAC_SCENE.REGISTER;
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: `a04-register-${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.REGISTER,
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 validateForm = () => {
const nextErrors = {
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
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;
const formValid = validateForm();
if (!agreed.value) agreementError.value = true;
if (!formValid || !agreed.value) return;
submitting.value = true;
try {
await appApi.registerWithPassword(
{
phone: phone.value,
passwordHash: calcMD5(password.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;
}
};
</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;
}
.auth-input {
flex: 1;
min-width: 0;
min-height: var(--app-touch-min);
color: #493323;
font-size: 30rpx;
}
.get-code {
flex: 0 0 auto;
padding-left: 14rpx;
border-left: 1rpx solid #d7bd94;
color: #a7160c;
font-size: 26rpx;
}
.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>