feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+824
View File
@@ -0,0 +1,824 @@
<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.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="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/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, runBackGuard } from "@/utils/navigation/gateway.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/auth/password-policy.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 submitting = ref(false);
const registrationCommitted = ref(false);
const isDirty = computed(
() =>
!registrationCommitted.value &&
Boolean(
phone.value ||
nickName.value ||
verificationCode.value ||
password.value ||
confirmPassword.value ||
agreed.value,
),
);
let feedbackTimer = null;
let pageActive = true;
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 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;
}
return smsVerification.requestCode();
};
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;
const registrationPayload = {
phone: phone.value,
nickName: nickName.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: nowrap;
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;
align-items: center;
min-width: 0;
padding-top: 2rpx;
}
.agreement-copy text {
white-space: nowrap;
}
.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>