feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+383
View File
@@ -0,0 +1,383 @@
<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>