Files
jiapuapp/pages/auth/reset-password.vue
T

645 lines
18 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 reset-page">
<view class="reset-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="reset-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="reset-password-phone"
><text class="required-mark">*</text>手机号</label
>
<input
id="reset-password-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="
sendingCode ||
submitting ||
(cooldownSeconds > 0 && phone.length > 0)
"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="
fieldErrors.phone ? 'reset-password-phone-error' : undefined
"
@input="handlePhoneInput"
/>
</view>
<text
v-if="fieldErrors.phone"
id="reset-password-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="reset-password-verification-code"
><text class="required-mark">*</text>验证码</label
>
<input
id="reset-password-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
? 'reset-password-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="reset-password-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="reset-password-new-password"
><text class="required-mark">*</text>新密码</label
>
<input
id="reset-password-new-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 ? 'reset-password-new-password-error' : undefined
"
@input="clearFieldError('password')"
/>
</view>
<text
v-if="fieldErrors.password"
id="reset-password-new-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--confirm" for="reset-password-confirm-password"
><text class="required-mark">*</text>确认新密码</label
>
<input
id="reset-password-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
? 'reset-password-confirm-password-error'
: undefined
"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text
v-if="fieldErrors.confirmPassword"
id="reset-password-confirm-password-error"
class="field-error"
role="alert"
>{{ fieldErrors.confirmPassword }}</text
>
</view>
</view>
<button
class="auth-plain-button reset-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-hover"
@click="submitReset"
>
<image
class="reset-submit__skin"
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="aspectFit"
/>
<text class="reset-submit__content">{{
submitting ? "提交中…" : "确认重设"
}}</text>
</button>
<text class="reset-tip">重设成功后请使用新密码登录</text>
<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"
/>
<AppDialog
:visible="successVisible"
title="密码已重设"
message="请返回登录页,使用新密码登录"
confirm-text="返回登录"
:close-on-mask="false"
@confirm="leaveResetSuccess"
>
<image
class="success-mark"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
</AppDialog>
<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 { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/auth/password-policy.js";
const phone = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const successVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const submitting = ref(false);
const fieldErrors = ref({
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const isDirty = computed(
() =>
!successVisible.value &&
Boolean(
phone.value ||
verificationCode.value ||
password.value ||
confirmPassword.value,
),
);
let feedbackTimer = null;
let pageActive = true;
const passwordResetSubmissionRequestController = createRequestController();
const passwordResetGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
// 重设成功不产生可跨页消费的数据;只有远端 PUT 已成功后才显示该终态。
const leaveResetSuccess = () => returnTo("A01", {});
const requestBack = () => {
if (successVisible.value) return leaveResetSuccess();
return 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();
passwordResetSubmissionRequestController.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.FORGOT_PASSWORD,
requestIdPrefix: "forgot-password",
phone,
isActive: () => pageActive,
showFeedback,
});
const {
tacVisible,
tacContext,
sendingCode,
cooldownSeconds,
sentPhone,
closeTac,
completeTac,
handleTacFailure,
handleTacError,
} = smsVerification;
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 = "";
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 submitReset = async () => {
if (submitting.value || sendingCode.value) return;
if (!validateForm()) return;
const resetPayload = {
phone: phone.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
};
const resetAttempt = passwordResetGuard.begin(resetPayload);
if (resetAttempt === null) {
showFeedback(
"上次重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
);
return;
}
submitting.value = true;
try {
await authApi.resetPassword(
resetPayload,
{ requestController: passwordResetSubmissionRequestController },
);
if (!pageActive) return;
successVisible.value = true;
} catch (error) {
if (!pageActive) return;
if (passwordResetGuard.recordFailure(resetAttempt, error)) {
showFeedback(
"密码重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
);
return;
}
if (!isRequestCancelled(error))
showFeedback(error.message || "密码重设失败,请稍后重试");
} finally {
if (pageActive) submitting.value = false;
}
};
</script>
<style scoped lang="scss">
.reset-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: 1rpx;
}
.reset-divider {
grid-column: 1;
grid-row: 3;
width: 300rpx;
height: 50rpx;
margin-top: 2rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2);
}
.form-content {
margin-top: 8rpx;
}
.field-block {
min-height: 114rpx;
}
.input-row {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
border-bottom: 1rpx solid #cfaa70;
}
.input-label {
flex: 0 0 162rpx;
color: #674b35;
font-size: clamp(17px, 30rpx, 22px);
}
.input-label--confirm {
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);
}
.placeholder {
color: #9f968d;
}
.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;
}
.field-error {
display: block;
padding-top: 4rpx;
color: #b42318;
font-size: clamp(13px, 21rpx, 16px);
line-height: max(1.35em, clamp(16px, 28rpx, 20px));
text-align: right;
}
.field-block--error .input-row {
border-bottom-color: #b42318;
}
.reset-submit {
display: grid;
place-items: center;
min-height: var(--app-touch-min);
margin-top: 26rpx;
}
.reset-submit__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.reset-submit__content {
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: clamp(19px, 36rpx, 24px);
letter-spacing: 6rpx;
}
.reset-tip {
display: block;
margin-top: 22rpx;
color: #806c58;
font-size: clamp(15px, 24rpx, 18px);
text-align: center;
}
.login-entry {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
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;
}
.success-mark {
width: 82rpx;
height: 82rpx;
margin: 8rpx auto 12rpx;
}
.tap-fade,
.button-hover {
opacity: 0.72;
}
</style>