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
+644
View File
@@ -0,0 +1,644 @@
<template>
<AuthPageShell class="auth-page reset-page">
<view class="reset-content">
<view class="page-heading">
<button
class="auth-plain-button back-button"
aria-label="返回登录"
hover-class="tap-fade"
@click="requestBack"
>
<image
class="back-button__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</button>
<text class="page-title">重设密码</text>
<text class="page-subtitle">验证手机号后设置新的登录密码</text>
<image
class="reset-divider"
src="/static/assets/modules/auth/transparent/title-divider.png"
mode="aspectFit"
/>
</view>
<view class="form-content">
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.phone }"
>
<view class="input-row">
<label class="input-label" for="reset-password-phone"
><text class="required-mark">*</text>手机号</label
>
<input
id="reset-password-phone"
v-model.trim="phone"
class="auth-input"
type="number"
maxlength="11"
:disabled="
sendingCode ||
submitting ||
(cooldownSeconds > 0 && phone.length > 0)
"
placeholder="请输入手机号"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.phone)"
:aria-describedby="
fieldErrors.phone ? 'reset-password-phone-error' : undefined
"
@input="handlePhoneInput"
/>
</view>
<text
v-if="fieldErrors.phone"
id="reset-password-phone-error"
class="field-error"
role="alert"
>{{ fieldErrors.phone }}</text
>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.verificationCode }"
>
<view class="input-row code-row">
<label class="input-label" for="reset-password-verification-code"
><text class="required-mark">*</text>验证码</label
>
<input
id="reset-password-verification-code"
v-model.trim="verificationCode"
class="auth-input"
type="number"
maxlength="4"
:disabled="submitting"
placeholder="请输入验证码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.verificationCode)"
:aria-describedby="
fieldErrors.verificationCode
? 'reset-password-verification-code-error'
: undefined
"
@input="clearFieldError('verificationCode')"
/>
<button
class="auth-plain-button get-code"
:class="{
'get-code--disabled': sendingCode || cooldownSeconds > 0,
}"
:disabled="sendingCode || submitting || cooldownSeconds > 0"
hover-class="tap-fade"
@click="prepareGetCode"
>
{{
sendingCode
? "请求中…"
: cooldownSeconds > 0
? `${cooldownSeconds}s 后重发`
: "获取验证码"
}}
</button>
</view>
<text
v-if="fieldErrors.verificationCode"
id="reset-password-verification-code-error"
class="field-error"
role="alert"
>{{ fieldErrors.verificationCode }}</text
>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.password }"
>
<view class="input-row">
<label class="input-label" for="reset-password-new-password"
><text class="required-mark">*</text>新密码</label
>
<input
id="reset-password-new-password"
v-model="password"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请设置新密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.password)"
:aria-describedby="
fieldErrors.password ? 'reset-password-new-password-error' : undefined
"
@input="clearFieldError('password')"
/>
</view>
<text
v-if="fieldErrors.password"
id="reset-password-new-password-error"
class="field-error"
role="alert"
>{{ fieldErrors.password }}</text
>
</view>
<view
class="field-block"
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
>
<view class="input-row">
<label class="input-label input-label--confirm" for="reset-password-confirm-password"
><text class="required-mark">*</text>确认新密码</label
>
<input
id="reset-password-confirm-password"
v-model="confirmPassword"
class="auth-input"
password
maxlength="32"
:disabled="submitting"
placeholder="请再次输入新密码"
placeholder-class="placeholder"
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
:aria-describedby="
fieldErrors.confirmPassword
? 'reset-password-confirm-password-error'
: undefined
"
@input="clearFieldError('confirmPassword')"
/>
</view>
<text
v-if="fieldErrors.confirmPassword"
id="reset-password-confirm-password-error"
class="field-error"
role="alert"
>{{ fieldErrors.confirmPassword }}</text
>
</view>
</view>
<button
class="auth-plain-button reset-submit"
:disabled="submitting || sendingCode || tacVisible"
:aria-busy="submitting"
hover-class="button-hover"
@click="submitReset"
>
<image
class="reset-submit__skin"
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="aspectFit"
/>
<text class="reset-submit__content">{{
submitting ? "提交中…" : "确认重设"
}}</text>
</button>
<text class="reset-tip">重设成功后请使用新密码登录</text>
<view class="login-entry">
<text>想起密码了</text>
<button
class="auth-plain-button login-entry__link"
:disabled="submitting || sendingCode || tacVisible"
hover-class="tap-fade"
@click="requestBack"
>
返回登录
</button>
</view>
</view>
<template #overlay>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppDialog
:visible="discardVisible"
title="放弃重设密码?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="successVisible"
title="密码已重设"
message="请返回登录页,使用新密码登录"
confirm-text="返回登录"
:close-on-mask="false"
@confirm="leaveResetSuccess"
>
<image
class="success-mark"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
</AppDialog>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
</template>
</AuthPageShell>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import AuthPageShell from "@/components/auth/PageShell.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 {
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
} from "@/utils/auth/verification.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
} from "@/utils/auth/password-policy.js";
const phone = ref("");
const verificationCode = ref("");
const password = ref("");
const confirmPassword = ref("");
const successVisible = ref(false);
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const submitting = ref(false);
const fieldErrors = ref({
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
});
const isDirty = computed(
() =>
!successVisible.value &&
Boolean(
phone.value ||
verificationCode.value ||
password.value ||
confirmPassword.value,
),
);
let feedbackTimer = null;
let pageActive = true;
const passwordResetSubmissionRequestController = createRequestController();
const passwordResetGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
// 重设成功不产生可跨页消费的数据;只有远端 PUT 已成功后才显示该终态。
const leaveResetSuccess = () => returnTo("A01", {});
const requestBack = () => {
if (successVisible.value) return leaveResetSuccess();
return runBackGuard({
transientOpen: tacVisible.value || discardVisible.value,
submitting: submitting.value || sendingCode.value,
dirty: isDirty.value,
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
smsVerification.dispose();
passwordResetSubmissionRequestController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
discardConfirmation.dispose();
});
onShow(() => smsVerification.syncCooldown());
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const smsVerification = useSmsVerification({
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
requestIdPrefix: "forgot-password",
phone,
isActive: () => pageActive,
showFeedback,
});
const {
tacVisible,
tacContext,
sendingCode,
cooldownSeconds,
sentPhone,
closeTac,
completeTac,
handleTacFailure,
handleTacError,
} = smsVerification;
const clearFieldError = (field) => {
fieldErrors.value[field] = "";
};
const handlePhoneInput = () => {
clearFieldError("phone");
if (!sentPhone.value || phone.value === sentPhone.value) return;
verificationCode.value = "";
sentPhone.value = "";
};
const prepareGetCode = async () => {
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0)
return;
if (!isAuthPhone(phone.value)) {
fieldErrors.value.phone = "请输入正确手机号";
return;
}
fieldErrors.value.phone = "";
return smsVerification.requestCode();
};
const validateForm = () => {
const nextErrors = {
phone: "",
verificationCode: "",
password: "",
confirmPassword: "",
};
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
if (sentPhone.value !== phone.value)
nextErrors.verificationCode = "请先获取当前手机号的验证码";
else if (!/^\d{4}$/.test(verificationCode.value))
nextErrors.verificationCode = "请输入 4 位验证码";
const passwordResult = validatePassword(password.value);
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入新密码";
else if (password.value !== confirmPassword.value)
nextErrors.confirmPassword = "两次密码输入不一致";
fieldErrors.value = nextErrors;
return !Object.values(nextErrors).some(Boolean);
};
const submitReset = async () => {
if (submitting.value || sendingCode.value) return;
if (!validateForm()) return;
const resetPayload = {
phone: phone.value,
passwordHash: calcMD5(password.value),
smsCode: verificationCode.value,
};
const resetAttempt = passwordResetGuard.begin(resetPayload);
if (resetAttempt === null) {
showFeedback(
"上次重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
);
return;
}
submitting.value = true;
try {
await authApi.resetPassword(
resetPayload,
{ requestController: passwordResetSubmissionRequestController },
);
if (!pageActive) return;
successVisible.value = true;
} catch (error) {
if (!pageActive) return;
if (passwordResetGuard.recordFailure(resetAttempt, error)) {
showFeedback(
"密码重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
);
return;
}
if (!isRequestCancelled(error))
showFeedback(error.message || "密码重设失败,请稍后重试");
} finally {
if (pageActive) submitting.value = false;
}
};
</script>
<style scoped lang="scss">
.reset-content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-start;
box-sizing: border-box;
width: 100%;
max-width: 480px;
min-width: 0;
margin: 0 auto;
padding: clamp(10px, 2.2vh, 20px) clamp(24px, 8.5vw, 44px);
}
.page-heading {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
justify-items: center;
min-height: 154rpx;
}
.back-button {
display: flex;
grid-row: 1 / -1;
grid-column: 1;
align-self: start;
justify-self: start;
align-items: center;
justify-content: center;
width: var(--app-touch-min);
height: var(--app-touch-min);
margin-top: 10rpx;
}
.back-button__icon {
width: 42rpx;
height: 42rpx;
transform: scaleX(-1);
}
.page-title {
grid-column: 1;
grid-row: 1;
color: #9f170f;
font-size: clamp(29px, 58rpx, 36px);
font-weight: 700;
letter-spacing: 7rpx;
}
.page-subtitle {
grid-column: 1;
grid-row: 2;
margin-top: 12rpx;
color: #806c58;
font-size: clamp(15px, 25rpx, 18px);
letter-spacing: 1rpx;
}
.reset-divider {
grid-column: 1;
grid-row: 3;
width: 300rpx;
height: 50rpx;
margin-top: 2rpx;
filter: brightness(0.68) saturate(1.5) contrast(1.2);
}
.form-content {
margin-top: 8rpx;
}
.field-block {
min-height: 114rpx;
}
.input-row {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
border-bottom: 1rpx solid #cfaa70;
}
.input-label {
flex: 0 0 162rpx;
color: #674b35;
font-size: clamp(17px, 30rpx, 22px);
}
.input-label--confirm {
flex-basis: 220rpx;
white-space: nowrap;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: #b42318;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1;
transform: translateY(-3rpx);
}
.auth-input {
flex: 1;
min-width: 0;
min-height: var(--app-touch-min);
color: #493323;
font-size: clamp(17px, 30rpx, 22px);
}
.placeholder {
color: #9f968d;
}
.get-code {
display: flex;
flex: 0 0 176rpx;
box-sizing: border-box;
align-items: center;
justify-content: flex-end;
padding: 0 8rpx;
border-left: 1rpx solid #d7bd94;
background: transparent !important;
color: #a7160c;
font-size: clamp(16px, 26rpx, 20px);
white-space: nowrap;
}
.get-code--disabled {
color: #9f968d;
}
.field-error {
display: block;
padding-top: 4rpx;
color: #b42318;
font-size: clamp(13px, 21rpx, 16px);
line-height: max(1.35em, clamp(16px, 28rpx, 20px));
text-align: right;
}
.field-block--error .input-row {
border-bottom-color: #b42318;
}
.reset-submit {
display: grid;
place-items: center;
min-height: var(--app-touch-min);
margin-top: 26rpx;
}
.reset-submit__skin {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.reset-submit__content {
z-index: 1;
grid-area: 1 / 1;
color: #fffaf0;
font-size: clamp(19px, 36rpx, 24px);
letter-spacing: 6rpx;
}
.reset-tip {
display: block;
margin-top: 22rpx;
color: #806c58;
font-size: clamp(15px, 24rpx, 18px);
text-align: center;
}
.login-entry {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
margin-top: auto;
color: #493323;
font-size: clamp(16px, 28rpx, 20px);
}
.login-entry__link {
display: flex;
align-items: center;
min-height: var(--app-touch-min);
margin-left: 8rpx;
color: #a7160c;
}
.success-mark {
width: 82rpx;
height: 82rpx;
margin: 8rpx auto 12rpx;
}
.tap-fade,
.button-hover {
opacity: 0.72;
}
</style>