Files
jiapuapp/pages/auth/register.vue
T

876 lines
24 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>
<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/title-divider.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="register-phone"
><text class="required-mark">*</text>手机号</label
>
<input
id="register-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 ? 'register-phone-error' : undefined
"
@input="handlePhoneInput"
/>
</view>
<text
v-if="fieldErrors.phone"
id="register-phone-error"
class="field-error"
role="alert"
>{{ fieldErrors.phone }}</text
>
</view>
<view class="field-block">
<view class="input-row">
<label class="input-label" for="register-nickname">昵称</label>
<input
id="register-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.referralCode }"
>
<view class="input-row">
<label class="input-label" for="register-referral-code">推荐码</label>
<input
id="register-referral-code"
v-model.trim="referralCode"
class="auth-input"
maxlength="64"
:disabled="submitting || registrationCommitted"
placeholder="选填,来自邀请人的推荐码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.referralCode)"
:aria-describedby="fieldErrors.referralCode ? 'register-referral-code-error' : undefined"
@input="clearFieldError('referralCode')"
/>
</view>
<text
v-if="fieldErrors.referralCode"
id="register-referral-code-error"
class="field-error"
role="alert"
>{{ fieldErrors.referralCode }}</text
>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<label class="input-label" for="register-verification-code"
><text class="required-mark">*</text>验证码</label
>
<input
id="register-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
? 'register-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="register-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 input-label--password" for="register-password"
><text class="required-mark">*</text>设置密码</label
>
<input
id="register-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 ? 'register-password-error' : undefined
"
@input="clearFieldError('password')"
/>
</view>
<text
v-if="fieldErrors.password"
id="register-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 input-label--password" for="register-confirm-password"
><text class="required-mark">*</text>确认密码</label
>
<input
id="register-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
? 'register-confirm-password-error'
: undefined
"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text
v-if="fieldErrors.confirmPassword"
id="register-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/scroll-primary.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/agreement-checked.png'
: '/static/assets/modules/auth/transparent/agreement-unchecked.png'
"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<view class="agreement-copy">
<text>我已阅读并同意</text>
<button
class="auth-plain-button agreement-link"
@click="openComplianceDocument('user_agreement')"
>
用户协议
</button>
<text></text>
<button
class="auth-plain-button agreement-link"
@click="openComplianceDocument('privacy_policy')"
>
隐私政策
</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, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/auth/PageShell.vue";
import TacVerification from "@/components/auth/TacVerification.vue";
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { authApi } from "@/services/api/auth-service.js";
import {
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
} from "@/utils/auth/verification.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
goRoot,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation/gateway.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/auth/password-policy.js";
const phone = ref("");
const nickName = ref("");
const referralCode = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const agreed = ref(false);
const agreementError = ref(false);
const fieldErrors = ref({
phone: "",
referralCode: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const submitting = ref(false);
const registrationCommitted = ref(false);
const isDirty = computed(
() =>
!registrationCommitted.value &&
Boolean(
phone.value ||
nickName.value ||
referralCode.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
agreed.value,
),
);
let feedbackTimer = null;
let pageActive = true;
onLoad((options = {}) => {
referralCode.value = String(options.referralCode || "").trim().slice(0, 64);
});
const registrationNavigationFailure =
"注册已完成,但暂时无法进入家谱,请再次点击进入";
const registrationSubmissionRequestController = createRequestController();
const registrationGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
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": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
smsVerification.dispose();
registrationSubmissionRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
discardConfirmation.dispose();
});
onShow(() => smsVerification.syncCooldown());
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const smsVerification = useSmsVerification({
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
requestIdPrefix: "register",
phone,
isActive: () => pageActive,
showFeedback,
});
const {
tacVisible,
tacContext,
sendingCode,
cooldownSeconds,
sentPhone,
closeTac,
completeTac,
handleTacFailure,
handleTacError,
} = smsVerification;
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 openComplianceDocument = (documentKey) => {
if (sendingCode.value || submitting.value || registrationCommitted.value)
return;
return openPage("M13", { documentKey }, "A04");
};
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;
}
return smsVerification.requestCode();
};
const validateForm = () => {
const nextErrors = {
phone: "",
referralCode: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (referralCode.value && !/^[A-Za-z0-9_-]{4,64}$/.test(referralCode.value)) {
nextErrors.referralCode = "推荐码应为 4 至 64 位字母、数字、短横线或下划线";
}
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;
const registrationPayload = {
phone: phone.value,
nickName: nickName.value,
referralCode: referralCode.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
};
const registrationAttempt = registrationGuard.begin(registrationPayload);
if (registrationAttempt === null) {
showFeedback(
"上次注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
);
return;
}
submitting.value = true;
try {
await authApi.registerWithPassword(
registrationPayload,
{ requestController: registrationSubmissionRequestController },
);
if (!pageActive) return;
registrationCommitted.value = true;
} catch (error) {
if (!pageActive) return;
if (registrationGuard.recordFailure(registrationAttempt, error)) {
showFeedback(
"注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
);
return;
}
if (!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: clamp(29px, 58rpx, 36px);
font-weight: 700;
letter-spacing: 7rpx;
}
.page-subtitle {
grid-column: 1;
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: clamp(15px, 25rpx, 18px);
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: clamp(17px, 30rpx, 22px);
}
.input-label--password {
flex-basis: 220rpx;
white-space: nowrap;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: #b42318;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1;
transform: translateY(-3rpx);
}
.auth-input {
flex: 1;
min-width: 0;
min-height: var(--app-touch-min);
color: #493323;
font-size: clamp(17px, 30rpx, 22px);
}
.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: clamp(16px, 26rpx, 20px);
white-space: nowrap;
}
.get-code--disabled {
color: #9f968d;
}
.placeholder {
color: #9f968d;
}
.field-error,
.agreement-error {
display: block;
color: #b42318;
font-size: clamp(13px, 21rpx, 16px);
line-height: max(1.35em, clamp(17px, 32rpx, 22px));
}
.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: clamp(19px, 36rpx, 24px);
letter-spacing: 6rpx;
}
.agreement-area {
position: relative;
min-height: 118rpx;
margin-top: 18rpx;
color: #493323;
}
.agreement-row {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
min-height: var(--app-touch-min);
font-size: clamp(12px, 22rpx, 14px);
line-height: 1.45;
}
.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);
box-sizing: border-box;
margin: 0 -12rpx 0 0;
padding-top: 0;
}
.agreement-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: center;
min-width: 0;
padding-top: 2rpx;
}
.agreement-copy text {
white-space: normal;
}
.agreement-link {
display: inline-flex;
align-items: center;
min-height: var(--app-touch-min);
color: #a7160c;
white-space: nowrap;
}
.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: clamp(16px, 28rpx, 20px);
}
.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>