384 lines
11 KiB
Vue
384 lines
11 KiB
Vue
<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.trim="phone"
|
||
type="number"
|
||
maxlength="11"
|
||
aria-label="新手机号"
|
||
placeholder="请输入新手机号"
|
||
@input="handlePhoneInput"
|
||
/>
|
||
</view>
|
||
<text v-if="errors.phone" class="field-error">{{ 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="短信验证码"
|
||
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" class="field-error">{{ 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 { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||
|
||
const phone = ref("");
|
||
const smsCode = ref("");
|
||
const errors = reactive({ phone: "", smsCode: "" });
|
||
const submittingPhoneChange = ref(false);
|
||
const discardVisible = ref(false);
|
||
const toastVisible = ref(false);
|
||
const toastMessage = ref("");
|
||
const formSnapshot = computed(() => JSON.stringify({ 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.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
|
||
errors.smsCode =
|
||
sentPhone.value !== phone.value
|
||
? "请先获取当前手机号的验证码"
|
||
: /^\d{4}$/.test(smsCode.value)
|
||
? ""
|
||
: "请输入 4 位验证码";
|
||
return !errors.phone && !errors.smsCode;
|
||
};
|
||
|
||
const submitPhoneChange = async () => {
|
||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
|
||
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
|
||
if (phoneChangeAttempt === null) {
|
||
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||
return;
|
||
}
|
||
submittingPhoneChange.value = true;
|
||
try {
|
||
await authApi.changePhone(
|
||
phoneChangePayload,
|
||
{ requestController: phoneChangeController },
|
||
);
|
||
if (!isPageActive) return;
|
||
phone.value = "";
|
||
smsCode.value = "";
|
||
sentPhone.value = "";
|
||
baseline.value = formSnapshot.value;
|
||
showToast("手机号换绑成功");
|
||
} 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 72rpx;
|
||
}
|
||
.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: 68rpx;
|
||
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: 68rpx;
|
||
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>
|