Files
jiapuapp/pages/profile/settings.vue
T

616 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>