Files
jiapuapp/pages/auth/a05-reset-password.vue
T
2026-07-27 06:50:23 +08:00

673 lines
19 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-05用途通过手机号验证后重设登录密码 -->
<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/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="a05-phone"><text class="required-mark">*</text>手机号</label>
<input
id="a05-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 ? 'a05-phone-error' : undefined"
@input="handlePhoneInput"
/>
</view>
<text v-if="fieldErrors.phone" id="a05-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="a05-verification-code"><text class="required-mark">*</text>验证码</label>
<input
id="a05-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 ? 'a05-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="a05-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="a05-password"><text class="required-mark">*</text>新密码</label>
<input
id="a05-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 ? 'a05-password-error' : undefined"
@input="clearFieldError('password')"
/>
</view>
<text v-if="fieldErrors.password" id="a05-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="a05-confirm-password"><text class="required-mark">*</text>确认新密码</label>
<input
id="a05-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 ? 'a05-confirm-password-error' : undefined"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text v-if="fieldErrors.confirmPassword" id="a05-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/a01-scroll-primary-v3.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/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 {
handleBackPress,
returnTo,
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 successVisible = ref(false);
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 sentPhone = ref("");
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 tacSequence = 0;
let pageActive = true;
const authRequestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
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 closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const cancelPendingRequest = () => {
authRequestController.abort();
sendingCode.value = false;
submitting.value = false;
};
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": () => {
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 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 = "";
sendingCode.value = true;
try {
const operationCode = AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD;
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: `a05-forgot-${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.FORGOT_PASSWORD,
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 submitReset = async () => {
if (submitting.value || sendingCode.value) return;
if (!validateForm()) return;
submitting.value = true;
try {
await appApi.resetPassword(
{
phone: phone.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
},
{ requestController: authRequestController },
);
if (!pageActive) return;
successVisible.value = true;
} catch (error) {
if (pageActive && !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: 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: 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: 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;
}
.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: 26rpx;
white-space: nowrap;
}
.get-code--disabled {
color: #9f968d;
}
.field-error {
display: block;
padding-top: 4rpx;
color: #b42318;
font-size: 21rpx;
line-height: 28rpx;
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: 36rpx;
letter-spacing: 6rpx;
}
.reset-tip {
display: block;
margin-top: 22rpx;
color: #806c58;
font-size: 24rpx;
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: 28rpx;
}
.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>