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

450 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.
<!-- 页面编号M-05用途换绑已登录账号的手机号 -->
<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/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 { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const phone = ref("");
const smsCode = ref("");
const sentPhone = ref("");
const errors = reactive({ phone: "", smsCode: "" });
const phoneState = ref("ready");
const cooldownSeconds = ref(0);
const tacVisible = ref(false);
const tacContext = ref(null);
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 requestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
let toastTimer = null;
let tacSequence = 0;
let pageActive = true;
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
}, 2200);
};
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const handlePhoneInput = () => {
errors.phone = "";
if (phone.value === sentPhone.value) return;
smsCode.value = "";
sentPhone.value = "";
errors.smsCode = "";
};
const sendSmsCode = async (requestedPhone, validToken) => {
await appApi.sendSmsCode(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
phone: requestedPhone,
...(validToken ? { validToken } : {}),
},
{ requestController },
);
if (!pageActive) return;
sentPhone.value = requestedPhone;
smsCooldown.start();
showToast("验证码已发送");
};
const prepareGetCode = async () => {
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
if (!isAuthPhone(phone.value)) {
errors.phone = "请输入正确手机号";
return;
}
errors.phone = "";
phoneState.value = "sending";
try {
const requestedPhone = phone.value;
const response = await appApi.getCaptchaRequirement(
{
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
},
{ requestController },
);
if (!pageActive) return;
if (phone.value !== requestedPhone) {
throw new Error("手机号已变化,请重新获取验证码");
}
const requirement = normalizeCaptchaRequirement(response);
if (!requirement.required) {
await sendSmsCode(requestedPhone);
return;
}
tacSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `m05-phone-change-${tacSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "安全验证暂不可用");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const completeTac = async (result) => {
const context = tacContext.value;
if (!context || phoneState.value !== "ready") return;
phoneState.value = "sending";
try {
const ticket = normalizeTacSuccess(result, context.requestId);
if (phone.value !== context.subject) {
throw new Error("手机号已变化,请重新验证");
}
closeTac();
await sendSmsCode(context.subject, ticket.validToken);
} catch (error) {
closeTac();
if (pageActive) {
if (isSmsDeliveryOutcomeUnknown(error)) {
sentPhone.value = context.subject;
smsCooldown.start();
showToast("发送结果未知,如收到短信可直接填写;60 秒后可重试");
} else if (!isRequestCancelled(error)) {
showToast(error?.message || "验证码发送失败");
}
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const handleTacFailure = ({ message } = {}) => {
showToast(message || "行为验证未通过,请重试");
};
const handleTacError = ({ message } = {}) => {
closeTac();
showToast(message || "安全验证暂不可用");
};
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;
phoneState.value = "submitting";
try {
await appApi.changePhone(
{ phone: phone.value, smsCode: smsCode.value },
{ requestController },
);
if (!pageActive) return;
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
showToast("手机号换绑成功");
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
}
} finally {
if (pageActive) phoneState.value = "ready";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
dirty: isDirty.value,
submitting: phoneState.value !== "ready",
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => {
requestController.abort();
return true;
},
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onShow(() => smsCooldown.sync());
onUnload(() => {
pageActive = false;
requestController.abort();
smsCooldown.dispose();
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 {
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;
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>