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
+615
View File
@@ -0,0 +1,615 @@
<template>
<view
class="about-page"
:class="{
'about-state--logging-out': logoutSubmitting,
'about-state--deactivating': deactivationBusy,
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"
><PageHeader title="关于家谱" custom-back @back="requestBack"
/></view>
<view class="page-content page-layer">
<view class="brand-card"
><image
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/><text>家谱</text><text>传承每一段值得珍藏的家族记忆</text
><text>版本 {{ appVersion }}</text></view
>
<view class="settings-list">
<view
v-for="settingsItem in agreementItems"
:key="settingsItem.key"
class="settings-row"
role="button"
:aria-label="settingsItem.label"
@click="openAgreement(settingsItem)"
><view
><text>{{ settingsItem.label }}</text
><text>{{ settingsItem.note }}</text></view
><image
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/></view>
</view>
<AppButton
block
type="secondary"
label="退出登录"
@click="logoutVisible = true"
/>
<view class="deactivate-entry">
<text>账号注销</text>
<text>注销后无法恢复家谱资料会按平台规则处理</text>
<button class="deactivate-link" @click="deactivationWarningVisible = true"
>申请注销账号</button
>
</view>
<view v-if="deactivationFormVisible" class="deactivate-panel">
<view class="deactivate-panel__heading">
<text>短信确认</text>
<text>验证码将发送至 {{ maskedDeactivationPhone }}完成验证后仍需再次确认注销</text>
</view>
<view class="form-row form-row--code">
<text>短信验证码</text>
<input
v-model.trim="deactivationSmsCode"
type="number"
maxlength="4"
aria-label="账号注销短信验证码"
placeholder="4 位验证码"
@input="deactivationFormError = ''"
/>
<button
class="code-action"
:disabled="deactivationBusy || cooldownSeconds > 0"
hover-class="code-action--pressed"
@click="requestDeactivationSms"
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
</view>
<text v-if="deactivationFormError" class="field-error">{{ deactivationFormError }}</text>
<AppButton
block
type="secondary"
:disabled="deactivationBusy"
:label="deactivationPhase === 'submitting' ? '正在注销' : '继续注销'"
@click="openFinalDeactivationConfirmation"
/>
</view>
</view>
<AppDialog
:visible="noticeVisible"
:close-on-mask="false"
eyebrow="版本说明"
:title="activeNotice.label"
confirm-text="关闭"
@confirm="noticeVisible = false"
@close="noticeVisible = false"
>
<text class="notice-copy">{{ activeNotice.copy }}</text>
</AppDialog>
<AppDialog
:visible="logoutVisible"
eyebrow="账号操作"
title="确认退出登录?"
message="退出后需要重新验证账号;本机保存的密码不会被保留。"
:confirm-text="logoutSubmitting ? '正在退出' : '确认退出'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmLogout"
@cancel="logoutVisible = false"
@close="logoutVisible = false"
/>
<AppDialog
:visible="deactivationWarningVisible"
eyebrow="高风险操作"
title="确认申请注销账号?"
message="账号注销不可恢复。请先完成当前绑定手机号的短信验证;验证通过后,仍需再次确认才会提交。"
confirm-text="继续验证"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="openDeactivationVerification"
@cancel="deactivationWarningVisible = false"
@close="deactivationWarningVisible = false"
/>
<AppDialog
:visible="finalDeactivationConfirmationVisible"
eyebrow="最后确认"
title="确定永久注销账号?"
message="确认后会注销你的账号,本操作无法撤销。"
:confirm-text="deactivationPhase === 'submitting' ? '正在提交' : '确认注销'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="submitAccountDeactivation"
@cancel="finalDeactivationConfirmationVisible = false"
@close="finalDeactivationConfirmationVisible = false"
/>
<TacVerification
:visible="tacVisible"
:context="tacContext"
@success="completeDeactivationTac"
@failure="handleTacFailure"
@error="handleTacError"
@cancel="closeTac"
/>
<AppToast :visible="toastVisible" :message="toastMessage" />
</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 manifest from "@/manifest.json";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { authApi } from "@/services/api/auth-service.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
} from "@/utils/auth/verification.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goRoot, handleBackPress, openPage, runBackGuard } from "@/utils/navigation/gateway.js";
import { session } from "@/utils/session.js";
const appVersion = manifest.versionName;
const agreementItems = [
{
key: "user_agreement",
label: "用户协议",
note: "了解账号与服务使用规则",
},
{
key: "privacy_policy",
label: "隐私政策",
note: "了解个人资料如何使用与保护",
},
{
key: "version",
label: "版本说明",
note: `当前版本 ${appVersion}`,
copy: `当前安装版本为 ${appVersion}`,
},
];
const activeNotice = reactive({ label: "", copy: "" });
const noticeVisible = ref(false);
const logoutVisible = ref(false);
const logoutSubmitting = ref(false);
const deactivationWarningVisible = ref(false);
const deactivationFormVisible = ref(false);
const finalDeactivationConfirmationVisible = ref(false);
const deactivationPhase = ref("ready");
const deactivationPhone = ref("");
const deactivationSmsCode = ref("");
const deactivationFormError = ref("");
const toastVisible = ref(false);
const toastMessage = ref("");
const logoutRequestController = createRequestController();
// 账号资料读取与最终注销严格串行,共享控制器可准确取消当前阶段;短信验证
// 由独立模块拥有,不再与注销提交共用取消槽。
const deactivationRequestController = createRequestController();
const accountDeactivationGuard = createNonIdempotentWriteGuard();
const maskedDeactivationPhone = computed(() =>
isAuthPhone(deactivationPhone.value)
? `${deactivationPhone.value.slice(0, 3)}****${deactivationPhone.value.slice(-4)}`
: "当前绑定手机号",
);
let toastTimer = null;
let pageActive = true;
const openAgreement = (settingsItem) => {
if (settingsItem.key === "version") {
activeNotice.label = settingsItem.label;
activeNotice.copy = settingsItem.copy;
noticeVisible.value = true;
return;
}
return openPage("M13", { documentKey: settingsItem.key }, "M10");
};
const showToast = (message) => {
toastMessage.value = message;
toastVisible.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
}, 2200);
};
const smsVerification = useSmsVerification({
operationCode: AUTH_VERIFICATION_OPERATION.ACCOUNT_DEACTIVATE,
requestIdPrefix: "account-deactivation",
phone: deactivationPhone,
isActive: () => pageActive,
showFeedback: showToast,
getErrorMessage: (error, fallback) =>
error?.code
? getRequestErrorMessage(error, fallback)
: error?.message || fallback,
});
const {
tacVisible,
tacContext,
sendingCode: sendingDeactivationCode,
cooldownSeconds,
sentPhone: smsSentToPhone,
closeTac,
completeTac: completeDeactivationTac,
handleTacFailure,
handleTacError,
} = smsVerification;
const deactivationBusy = computed(
() => deactivationPhase.value !== "ready" || sendingDeactivationCode.value,
);
const deactivationErrorMessage = (error, fallback) =>
error?.code
? getRequestErrorMessage(error, fallback)
: error?.message || fallback;
const openDeactivationVerification = async () => {
if (deactivationPhase.value !== "ready") return;
deactivationPhase.value = "loading";
try {
const profile = await profileApi.getProfile({
requestController: deactivationRequestController,
});
if (!isAuthPhone(profile.phone)) {
throw new Error("你的账号还没有绑定可用手机号,暂不能注销");
}
if (!pageActive) return;
deactivationPhone.value = profile.phone;
deactivationWarningVisible.value = false;
deactivationFormVisible.value = true;
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
showToast(deactivationErrorMessage(error, "账号资料暂时无法读取"));
}
} finally {
if (pageActive) deactivationPhase.value = "ready";
}
};
const requestDeactivationSms = async () => {
if (deactivationBusy.value || cooldownSeconds.value > 0) return;
if (!isAuthPhone(deactivationPhone.value)) {
deactivationFormError.value = "你的账号还没有绑定可用手机号";
return;
}
deactivationFormError.value = "";
return smsVerification.requestCode();
};
const openFinalDeactivationConfirmation = () => {
deactivationFormError.value =
smsSentToPhone.value !== deactivationPhone.value
? "请先获取当前手机号的验证码"
: /^\d{4}$/.test(deactivationSmsCode.value)
? ""
: "请输入 4 位验证码";
if (deactivationFormError.value) return;
finalDeactivationConfirmationVisible.value = true;
};
const submitAccountDeactivation = async () => {
if (deactivationBusy.value) return;
const deactivationPayload = { smsCode: deactivationSmsCode.value };
const deactivationAttempt = accountDeactivationGuard.begin(deactivationPayload);
if (deactivationAttempt === null) {
showToast(
"上次注销结果暂时无法确认,请重新登录确认账号状态,不要重复提交。",
);
return;
}
deactivationPhase.value = "submitting";
try {
await authApi.deactivateAccount(
deactivationPayload,
{ requestController: deactivationRequestController },
);
if (!pageActive) return;
// 只有服务端明确确认注销成功后才能清除本机会话;请求结果未知时
// 保留登录态,用户仍可重试或联系支持,不能误导为账号已经注销。
session.clear();
finalDeactivationConfirmationVisible.value = false;
deactivationFormVisible.value = false;
goRoot("A01");
} catch (error) {
if (!pageActive) return;
if (accountDeactivationGuard.recordFailure(deactivationAttempt, error)) {
showToast(
"注销结果暂时无法确认,请重新登录确认账号状态,不要重复提交。",
);
return;
}
if (!isRequestCancelled(error))
showToast(deactivationErrorMessage(error, "注销未完成,请稍后重试"));
} finally {
if (pageActive) deactivationPhase.value = "ready";
}
};
const confirmLogout = async () => {
if (logoutSubmitting.value) return false;
logoutSubmitting.value = true;
try {
await authApi.logout({ requestController: logoutRequestController });
} catch {
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
} finally {
session.clear();
logoutVisible.value = false;
logoutSubmitting.value = false;
}
return goRoot("A01");
};
const closeActiveDialog = () => {
if (tacVisible.value) closeTac();
else if (finalDeactivationConfirmationVisible.value) {
finalDeactivationConfirmationVisible.value = false;
} else if (deactivationWarningVisible.value) {
deactivationWarningVisible.value = false;
}
else if (logoutVisible.value) logoutVisible.value = false;
else if (noticeVisible.value) noticeVisible.value = false;
else deactivationFormVisible.value = false;
};
const requestBack = () =>
runBackGuard({
transientOpen:
noticeVisible.value ||
logoutVisible.value ||
deactivationWarningVisible.value ||
finalDeactivationConfirmationVisible.value ||
deactivationFormVisible.value ||
tacVisible.value,
submitting:
logoutSubmitting.value || deactivationBusy.value,
"close-transient": closeActiveDialog,
"block-submitting": () => true,
});
onBackPress((event) => handleBackPress(event, requestBack));
onShow(() => smsVerification.syncCooldown());
onUnload(() => {
pageActive = false;
logoutRequestController.abort();
deactivationRequestController.abort();
smsVerification.dispose();
clearTimeout(toastTimer);
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.about-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-layer {
z-index: 1;
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
}
.brand-card {
@include adaptive-profile-summary;
display: flex;
min-height: 300rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 36rpx 44rpx;
text-align: center;
}
.brand-card image {
width: 92rpx;
height: 106rpx;
}
.brand-card text {
display: block;
}
.brand-card text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(20px, 40rpx, 26px);
font-weight: 700;
letter-spacing: 4rpx;
}
.brand-card text:nth-child(3) {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.brand-card text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
}
.settings-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 22rpx;
}
.settings-row {
@include adaptive-profile-field;
display: grid;
grid-template-columns: minmax(0, 1fr) 32rpx;
min-height: 104rpx;
align-items: center;
gap: 18rpx;
padding: 18rpx 30rpx;
}
.settings-row view {
min-width: 0;
}
.settings-row text {
display: block;
overflow-wrap: anywhere;
}
.settings-row text:first-child {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.settings-row text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
}
.settings-row image {
width: 30rpx;
height: 30rpx;
}
.page-content > .app-button {
margin-top: 28rpx;
}
.notice-copy {
display: block;
width: 100%;
margin-top: 22rpx;
color: #513a28;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.75;
text-align: left;
white-space: pre-wrap;
}
.deactivate-entry,
.deactivate-panel {
@include adaptive-profile-content;
margin-top: 28rpx;
}
.deactivate-entry {
padding: 26rpx 34rpx;
}
.deactivate-entry text,
.deactivate-panel__heading text {
display: block;
}
.deactivate-entry text:first-child,
.deactivate-panel__heading text:first-child {
color: #9f170f;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
}
.deactivate-entry text:nth-child(2),
.deactivate-panel__heading text:last-child {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.55;
}
.deactivate-link {
width: auto;
min-height: 54rpx;
margin: 16rpx 0 0;
padding: 0;
border: 0;
background: transparent;
color: #9f170f;
font-size: clamp(13px, 21rpx, 16px);
font-weight: 700;
line-height: 1.3;
text-align: left;
}
.deactivate-link::after {
border: 0;
}
.deactivate-panel {
padding: 26rpx 34rpx 30rpx;
}
.form-row {
display: grid;
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;
margin-top: 16rpx;
}
.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,
.deactivate-panel .app-button::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;
}
.deactivate-panel .app-button {
margin-top: 24rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.deactivate-entry,
.deactivate-panel {
padding-right: 26rpx;
padding-left: 26rpx;
}
.form-row--code {
grid-template-columns: 170rpx minmax(0, 1fr) 190rpx;
gap: 8rpx;
}
.code-action {
width: 190rpx;
}
}
</style>