Files
jiapuapp/pages/profile/change-phone.vue
T

415 lines
12 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>
<view
class="phone-page"
:class="{
'phone-state--sending': phoneState === 'sending',
'phone-state--submitting': phoneState === 'submitting',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="换绑手机号" custom-back @back="requestBack"
/></view>
<view class="page-content page-layer">
<view class="security-tip"
><text>验证新手机号</text
><text>完成安全验证和短信校验后即可更新你的登录手机号</text></view
>
<view class="form-panel">
<view class="field-block">
<view class="form-row">
<text>当前密码</text>
<input
v-model="currentPassword"
password
maxlength="32"
aria-label="当前密码"
:aria-invalid="Boolean(errors.currentPassword)"
:aria-describedby="errors.currentPassword ? 'change-phone-password-error' : undefined"
placeholder="请输入当前登录密码"
@input="errors.currentPassword = ''"
/>
</view>
<text v-if="errors.currentPassword" id="change-phone-password-error" class="field-error" role="alert">{{ errors.currentPassword }}</text>
</view>
<view class="field-block">
<view class="form-row">
<text>新手机号</text>
<input
v-model.trim="phone"
type="number"
maxlength="11"
aria-label="新手机号"
:aria-invalid="Boolean(errors.phone)"
:aria-describedby="errors.phone ? 'change-phone-error' : undefined"
placeholder="请输入新手机号"
@input="handlePhoneInput"
/>
</view>
<text v-if="errors.phone" id="change-phone-error" class="field-error" role="alert">{{ errors.phone }}</text>
</view>
<view class="field-block">
<view class="form-row form-row--code">
<text>短信验证码</text>
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
aria-label="短信验证码"
:aria-invalid="Boolean(errors.smsCode)"
:aria-describedby="errors.smsCode ? 'change-phone-code-error' : undefined"
placeholder="4 位验证码"
@input="errors.smsCode = ''"
/>
<button
class="code-action"
:disabled="phoneState !== 'ready' || cooldownSeconds > 0"
hover-class="code-action--pressed"
@click="prepareGetCode"
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
</view>
<text v-if="errors.smsCode" id="change-phone-code-error" class="field-error" role="alert">{{ errors.smsCode }}</text>
</view>
</view>
<AppButton
block
:disabled="phoneState !== 'ready'"
:label="phoneState === 'submitting' ? '正在换绑' : '确认换绑手机号'"
@click="submitPhoneChange"
/>
</view>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppToast :visible="toastVisible" :message="toastMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃手机号换绑?"
message="新手机号还没有保存。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.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 { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
} from "@/utils/auth/verification.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { calcMD5 } from "@/utils/md5.js";
import { session } from "@/utils/session.js";
const currentPassword = ref("");
const phone = ref("");
const smsCode = ref("");
const errors = reactive({ currentPassword: "", phone: "", smsCode: "" });
const submittingPhoneChange = ref(false);
const discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
const formSnapshot = computed(() => JSON.stringify({ currentPassword: currentPassword.value, phone: phone.value, smsCode: smsCode.value }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const phoneChangeController = createRequestController();
const phoneChangeGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
let toastTimer = null;
let isPageActive = true;
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
}, 2200);
};
const smsVerification = useSmsVerification({
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
requestIdPrefix: "phone-change",
phone,
isActive: () => isPageActive,
showFeedback: showToast,
getErrorMessage: (error, fallback) =>
error?.code
? getRequestErrorMessage(error, fallback)
: error?.message || fallback,
});
const {
tacVisible,
tacContext,
sendingCode,
cooldownSeconds,
sentPhone,
closeTac,
completeTac,
handleTacFailure,
handleTacError,
} = smsVerification;
const phoneState = computed(() =>
submittingPhoneChange.value
? "submitting"
: sendingCode.value
? "sending"
: "ready",
);
const handlePhoneInput = () => {
errors.phone = "";
if (phone.value === sentPhone.value) return;
smsCode.value = "";
sentPhone.value = "";
errors.smsCode = "";
};
const prepareGetCode = async () => {
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
errors.phone = "请输入正确手机号";
return;
}
errors.phone = "";
return smsVerification.requestCode();
};
const validateForm = () => {
errors.currentPassword = currentPassword.value ? "" : "请输入当前密码";
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
errors.smsCode =
sentPhone.value !== phone.value
? "请先获取新手机号的验证码"
: /^\d{4}$/.test(smsCode.value)
? ""
: "请输入 4 位验证码";
return !errors.currentPassword && !errors.phone && !errors.smsCode;
};
const submitPhoneChange = async () => {
if (phoneState.value !== "ready" || !validateForm()) return;
const phoneChangePayload = {
phone: phone.value,
smsCode: smsCode.value,
currentPasswordHash: calcMD5(currentPassword.value),
};
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
if (phoneChangeAttempt === null) {
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
return;
}
submittingPhoneChange.value = true;
try {
await authApi.changePhone(
phoneChangePayload,
{ requestController: phoneChangeController },
);
if (!isPageActive) return;
session.clear();
await goRoot("A01");
if (!isPageActive) return;
currentPassword.value = "";
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
} catch (error) {
if (!isPageActive) return;
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
showToast("换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
return;
}
if (!isRequestCancelled(error))
showToast(error?.code ? getRequestErrorMessage(error, "换绑未完成,请稍后重试") : error?.message || "换绑未完成,请稍后重试");
} finally {
if (isPageActive) submittingPhoneChange.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value !== "ready",
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onShow(() => smsVerification.syncCooldown());
onUnload(() => {
isPageActive = false;
smsVerification.dispose();
phoneChangeController.abort();
discardConfirmation.dispose();
clearTimeout(toastTimer);
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.phone-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-layer {
z-index: 1;
}
.page-content {
flex: 1;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.security-tip,
.form-panel {
@include adaptive-profile-content;
}
.security-tip {
min-height: 170rpx;
padding: 38rpx 44rpx;
text-align: center;
}
.security-tip text {
display: block;
}
.security-tip text:first-child {
color: $ink;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
}
.security-tip text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.form-panel {
margin-top: 20rpx;
padding: 22rpx 34rpx 30rpx;
}
.field-block + .field-block {
margin-top: 6rpx;
}
.form-row {
display: grid;
grid-template-columns: 170rpx minmax(0, 1fr);
min-height: 92rpx;
align-items: center;
gap: 12rpx;
border-bottom: 1px solid rgba(181, 137, 63, 0.42);
}
.form-row--code {
grid-template-columns: 170rpx minmax(0, 1fr) 198rpx;
}
.form-row > text {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
white-space: nowrap;
}
.form-row input {
width: auto;
min-width: 0;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.code-action {
display: inline-flex;
align-items: center;
justify-content: center;
justify-self: end;
width: 198rpx;
min-height: 80rpx;
margin: 0;
padding: 0 14rpx;
box-sizing: border-box;
border: 1rpx solid rgba(159, 23, 15, 0.64);
border-radius: 10rpx;
background: rgba(255, 250, 238, 0.96);
box-shadow: inset 0 0 0 3rpx rgba(213, 176, 104, 0.18);
color: $brand-red;
font-size: clamp(13px, 20rpx, 15px);
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.code-action::after {
border: 0;
}
.code-action--pressed {
background: rgba(248, 232, 201, 0.96);
}
.code-action[disabled] {
border-color: rgba(128, 89, 49, 0.28);
color: #9d8b76;
opacity: 1;
}
.field-error {
display: block;
padding-top: 7rpx;
color: #b42318;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.4;
text-align: right;
}
.page-content > .app-button {
margin-top: 28rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.form-panel {
padding-right: 26rpx;
padding-left: 26rpx;
}
.form-row {
grid-template-columns: 170rpx minmax(0, 1fr);
gap: 8rpx;
}
.form-row--code {
grid-template-columns: 170rpx minmax(0, 1fr) 190rpx;
}
.code-action {
width: 190rpx;
}
}
</style>