修改未完成
This commit is contained in:
@@ -1,28 +1,324 @@
|
||||
<!-- 页面编号:M-05;用途:换绑手机号。缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭。 -->
|
||||
<!-- 页面编号:M-05;用途:换绑已登录账号的手机号。 -->
|
||||
<template>
|
||||
<view class="phone-page"
|
||||
><ModulePageBackground module="profile" /><view class="page-header"
|
||||
><PageHeader
|
||||
title="换绑手机号"
|
||||
custom-back
|
||||
@back="backToSecurity" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>暂不能在线换绑手机号</text
|
||||
><text>为保护账户安全,请联系管理员协助办理手机号换绑。</text
|
||||
><AppButton
|
||||
block
|
||||
label="返回账号与安全"
|
||||
@click="backToSecurity" /></view></view
|
||||
></view>
|
||||
<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 = ''"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="phoneState !== 'ready' || cooldownSeconds > 0"
|
||||
:label="cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码'"
|
||||
@click="prepareGetCode"
|
||||
/>
|
||||
</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 { returnTo } from "@/utils/navigation.js";
|
||||
const backToSecurity = () => returnTo("M03");
|
||||
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">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.phone-page {
|
||||
@@ -31,36 +327,100 @@ const backToSecurity = () => returnTo("M03");
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
.security-tip,
|
||||
.form-panel {
|
||||
@include adaptive.adaptive-profile-content;
|
||||
}
|
||||
.security-tip {
|
||||
min-height: 170rpx;
|
||||
padding: 38rpx 44rpx;
|
||||
text-align: center;
|
||||
@include adaptive.adaptive-family-content;
|
||||
}
|
||||
.state-card text {
|
||||
.security-tip text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
.security-tip text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
.security-tip text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card .app-button {
|
||||
.form-panel {
|
||||
margin-top: 20rpx;
|
||||
padding: 22rpx 34rpx 30rpx;
|
||||
}
|
||||
.field-block + .field-block {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx 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: 142rpx minmax(0, 1fr) 170rpx;
|
||||
}
|
||||
.form-row > text {
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-row input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.form-row--code .app-button {
|
||||
justify-self: end;
|
||||
width: 170rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
padding-top: 7rpx;
|
||||
color: #b42318;
|
||||
font-size: 20rpx;
|
||||
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: 120rpx minmax(0, 1fr);
|
||||
gap: 8rpx;
|
||||
}
|
||||
.form-row--code {
|
||||
grid-template-columns: 120rpx minmax(0, 1fr) 150rpx;
|
||||
}
|
||||
.form-row--code .app-button {
|
||||
width: 150rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user