616 lines
18 KiB
Vue
616 lines
18 KiB
Vue
<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 calc(72rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.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: 80rpx;
|
||
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: 80rpx;
|
||
color: $ink;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
}
|
||
.code-action {
|
||
justify-self: end;
|
||
width: 198rpx;
|
||
min-height: 80rpx;
|
||
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>
|