feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<view class="invitation-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="我的活动邀请" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="page-content page-layer">
|
||||
<AppLoading v-if="ceremonyInvitationListState === 'loading'" text="正在加载活动邀请" />
|
||||
<view v-else-if="ceremonyInvitationListState === 'error'" class="state-card">
|
||||
<text>暂时无法加载活动邀请</text>
|
||||
<text>请检查网络后重新查看。</text>
|
||||
<AppButton block type="secondary" label="重新查看" @click="loadInvitations" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyInvitationListState === 'empty'" class="state-card">
|
||||
<text>暂时没有活动邀请</text>
|
||||
<text>收到家族礼仪活动邀请后,会在这里显示。</text>
|
||||
<AppButton block type="secondary" label="返回我的" @click="requestBack" />
|
||||
</view>
|
||||
<view v-else class="invitation-list">
|
||||
<text class="page-note">你可以在这里选择接受或拒绝待处理的活动邀请。</text>
|
||||
<text v-if="actionError" class="action-error">{{ actionError }}</text>
|
||||
<view
|
||||
v-for="invitation in invitations"
|
||||
:key="invitation.id"
|
||||
class="invitation-card"
|
||||
>
|
||||
<view class="invitation-card__heading">
|
||||
<text>{{ invitation.ceremonyTitle || "未命名活动" }}</text>
|
||||
<text :class="`status status--${invitation.inviteStatus.toLowerCase()}`">
|
||||
{{ invitationStatusLabel(invitation.inviteStatus) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="invitation.ceremonyTime" class="invitation-card__meta">
|
||||
时间:{{ invitation.ceremonyTime }}
|
||||
</text>
|
||||
<text v-if="invitationLocation(invitation)" class="invitation-card__meta">
|
||||
地点:{{ invitationLocation(invitation) }}
|
||||
</text>
|
||||
<text v-if="invitation.deliveredTime" class="invitation-card__meta">
|
||||
送达:{{ invitation.deliveredTime }}
|
||||
</text>
|
||||
<text v-if="invitation.responseTime" class="invitation-card__meta">
|
||||
处理时间:{{ invitation.responseTime }}
|
||||
</text>
|
||||
<view
|
||||
v-if="invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING"
|
||||
class="invitation-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="Boolean(respondingId)"
|
||||
label="拒绝"
|
||||
@click="openResponseDialog(invitation, CEREMONY_INVITATION_STATUS.DECLINED)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
:disabled="Boolean(respondingId)"
|
||||
:label="respondingId === invitation.id ? '正在提交' : '接受'"
|
||||
@click="openResponseDialog(invitation, CEREMONY_INVITATION_STATUS.ACCEPTED)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(responseTarget)"
|
||||
:close-on-mask="false"
|
||||
:title="responseDialogTitle"
|
||||
:message="responseDialogMessage"
|
||||
:confirm-text="responseTarget?.status === CEREMONY_INVITATION_STATUS.ACCEPTED ? '确认接受' : '确认拒绝'"
|
||||
cancel-text="暂不处理"
|
||||
show-cancel
|
||||
@confirm="submitResponse"
|
||||
@cancel="closeResponseDialog"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
CEREMONY_INVITATION_STATUS,
|
||||
CEREMONY_INVITATION_STATUS_LABELS
|
||||
} from "@/services/api/ceremony-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const invitations = ref([]);
|
||||
const ceremonyInvitationListState = ref("loading");
|
||||
const actionError = ref("");
|
||||
const responseTarget = ref(null);
|
||||
const respondingId = ref("");
|
||||
const invitationListController = createRequestController();
|
||||
const invitationResponseController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const responseDialogTitle = computed(() =>
|
||||
responseTarget.value?.status === CEREMONY_INVITATION_STATUS.ACCEPTED
|
||||
? "接受活动邀请?"
|
||||
: "拒绝活动邀请?",
|
||||
);
|
||||
const responseDialogMessage = computed(() => {
|
||||
const title = responseTarget.value?.item?.ceremonyTitle || "此活动";
|
||||
return responseTarget.value?.status === CEREMONY_INVITATION_STATUS.ACCEPTED
|
||||
? `确认接受「${title}」的邀请?`
|
||||
: `确认拒绝「${title}」的邀请?`;
|
||||
});
|
||||
|
||||
const invitationStatusLabel = (status) =>
|
||||
CEREMONY_INVITATION_STATUS_LABELS[status] || "暂未确认";
|
||||
const invitationLocation = (invitation) =>
|
||||
[invitation.location, invitation.locationAddress].filter(Boolean).join(" · ");
|
||||
|
||||
const loadInvitations = async () => {
|
||||
if (respondingId.value) return;
|
||||
invitationListController.abort();
|
||||
ceremonyInvitationListState.value = "loading";
|
||||
actionError.value = "";
|
||||
try {
|
||||
const rows = await ceremonyApi.getMyCeremonyInvitations({
|
||||
requestController: invitationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
invitations.value = rows;
|
||||
ceremonyInvitationListState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
ceremonyInvitationListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openResponseDialog = (invitation, status) => {
|
||||
if (
|
||||
respondingId.value ||
|
||||
invitation.inviteStatus !== CEREMONY_INVITATION_STATUS.PENDING
|
||||
) return;
|
||||
responseTarget.value = { item: invitation, status };
|
||||
actionError.value = "";
|
||||
};
|
||||
const closeResponseDialog = () => {
|
||||
if (!respondingId.value) responseTarget.value = null;
|
||||
};
|
||||
const submitResponse = async () => {
|
||||
const target = responseTarget.value;
|
||||
if (!target || respondingId.value) return;
|
||||
respondingId.value = target.item.id;
|
||||
actionError.value = "";
|
||||
try {
|
||||
await ceremonyApi.respondToCeremonyInvitation(
|
||||
target.item.genealogyId,
|
||||
target.item.ceremonyId,
|
||||
{ inviteStatus: target.status },
|
||||
{ requestController: invitationResponseController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
responseTarget.value = null;
|
||||
respondingId.value = "";
|
||||
await loadInvitations();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
responseTarget.value = null;
|
||||
actionError.value = getRequestErrorMessage(error, "处理未完成,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) respondingId.value = "";
|
||||
}
|
||||
};
|
||||
const requestBack = () => returnTo("M01", {});
|
||||
|
||||
onShow(() => {
|
||||
if (!respondingId.value) loadInvitations();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
invitationListController.abort();
|
||||
invitationResponseController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.invitation-page {
|
||||
min-height: 100vh;
|
||||
color: #35251d;
|
||||
}
|
||||
|
||||
.page-layer,
|
||||
.page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 36rpx 32rpx 68rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.invitation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
padding: 34rpx 30rpx;
|
||||
border: 1rpx solid rgba(172, 121, 37, 0.48);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 249, 236, 0.9);
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 44, 20, 0.08);
|
||||
}
|
||||
|
||||
.state-card {
|
||||
margin-top: 32rpx;
|
||||
text-align: center;
|
||||
color: #76665b;
|
||||
}
|
||||
|
||||
.state-card text:first-child {
|
||||
color: #3d2b20;
|
||||
font-size: clamp(20px, 36rpx, 25px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invitation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.page-note {
|
||||
color: #887467;
|
||||
font-size: clamp(16px, 26rpx, 19px);
|
||||
}
|
||||
|
||||
.action-error {
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(177, 43, 31, 0.1);
|
||||
color: #a22b20;
|
||||
font-size: clamp(16px, 26rpx, 19px);
|
||||
}
|
||||
|
||||
.invitation-card__heading,
|
||||
.invitation-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.invitation-card__heading text:first-child {
|
||||
flex: 1;
|
||||
color: #40291e;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invitation-card__meta {
|
||||
color: #746256;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 0 0 auto;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.status--pending {
|
||||
background: rgba(183, 124, 30, 0.13);
|
||||
color: #9c6412;
|
||||
}
|
||||
|
||||
.status--accepted {
|
||||
background: rgba(47, 113, 71, 0.12);
|
||||
color: #2f7147;
|
||||
}
|
||||
|
||||
.status--declined,
|
||||
.status--canceled {
|
||||
background: rgba(137, 67, 51, 0.1);
|
||||
color: #874333;
|
||||
}
|
||||
|
||||
.invitation-card__actions {
|
||||
justify-content: flex-end;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-04;用途:修改登录密码。 -->
|
||||
<template>
|
||||
<view
|
||||
class="password-page"
|
||||
@@ -58,7 +57,7 @@
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃密码填写?"
|
||||
message="当前密码和新密码尚未提交服务器。"
|
||||
message="新密码还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -77,17 +76,19 @@ import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
validatePassword,
|
||||
} from "@/utils/validation.js";
|
||||
} from "@/utils/auth/password-policy.js";
|
||||
|
||||
const passwordForm = reactive({ current: "", next: "", confirm: "" });
|
||||
const passwordVisible = reactive({
|
||||
@@ -106,7 +107,8 @@ const passwordFields = [
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let timer = null;
|
||||
const passwordRequestController = createRequestController();
|
||||
const passwordChangeRequestController = createRequestController();
|
||||
const passwordChangeGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const formSnapshot = computed(() => JSON.stringify(passwordForm));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
@@ -141,14 +143,22 @@ const validateForm = () => {
|
||||
};
|
||||
const savePassword = async () => {
|
||||
if (!validateForm() || passwordState.value === "saving") return;
|
||||
const passwordChangePayload = {
|
||||
oldPasswordHash: calcMD5(passwordForm.current),
|
||||
newPasswordHash: calcMD5(passwordForm.next),
|
||||
};
|
||||
const passwordChangeAttempt = passwordChangeGuard.begin(passwordChangePayload);
|
||||
if (passwordChangeAttempt === null) {
|
||||
showToast(
|
||||
"上次修改结果暂时无法确认,请重新登录验证新密码,不要重复提交",
|
||||
);
|
||||
return;
|
||||
}
|
||||
passwordState.value = "saving";
|
||||
try {
|
||||
await appApi.changePassword(
|
||||
{
|
||||
oldPasswordHash: calcMD5(passwordForm.current),
|
||||
newPasswordHash: calcMD5(passwordForm.next),
|
||||
},
|
||||
{ requestController: passwordRequestController },
|
||||
await authApi.changePassword(
|
||||
passwordChangePayload,
|
||||
{ requestController: passwordChangeRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
passwordForm.current = "";
|
||||
@@ -157,9 +167,19 @@ const savePassword = async () => {
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("密码修改成功");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "密码修改失败,请稍后重试");
|
||||
if (!pageActive) return;
|
||||
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
|
||||
showToast(
|
||||
"密码修改结果暂时无法确认,请重新登录验证新密码,不要重复提交",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showToast(
|
||||
error?.code
|
||||
? getRequestErrorMessage(error, "密码修改失败,请稍后重试")
|
||||
: error?.message || "密码修改失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
if (pageActive) passwordState.value = "ready";
|
||||
}
|
||||
@@ -176,7 +196,7 @@ const requestBack = () =>
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
passwordRequestController.abort();
|
||||
passwordChangeRequestController.abort();
|
||||
clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-05;用途:换绑已登录账号的手机号。 -->
|
||||
<template>
|
||||
<view
|
||||
class="phone-page"
|
||||
@@ -14,7 +13,7 @@
|
||||
<view class="page-content page-layer">
|
||||
<view class="security-tip"
|
||||
><text>验证新手机号</text
|
||||
><text>完成行为验证和短信校验后,立即更新当前账号的登录手机号。</text></view
|
||||
><text>完成安全验证和短信校验后,即可更新你的登录手机号。</text></view
|
||||
>
|
||||
<view class="form-panel">
|
||||
<view class="field-block">
|
||||
@@ -73,7 +72,7 @@
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃手机号换绑?"
|
||||
message="新手机号和验证码尚未提交服务器。"
|
||||
message="新手机号还没有保存。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -91,46 +90,34 @@ 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/TacVerification.vue";
|
||||
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
} from "@/utils/auth/verification.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.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 sentPhone = ref("");
|
||||
const errors = reactive({ phone: "", smsCode: "" });
|
||||
const phoneState = ref("ready");
|
||||
const cooldownSeconds = ref(0);
|
||||
const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
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 requestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const phoneChangeController = createRequestController();
|
||||
const phoneChangeGuard = createNonIdempotentWriteGuard();
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -138,8 +125,7 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
let toastTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
let isPageActive = true;
|
||||
|
||||
const showToast = (message) => {
|
||||
toastMessage.value = message;
|
||||
@@ -150,10 +136,35 @@ const showToast = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
};
|
||||
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 = "";
|
||||
@@ -163,21 +174,6 @@ const handlePhoneInput = () => {
|
||||
errors.smsCode = "";
|
||||
};
|
||||
|
||||
const sendSmsCode = async (requestedPhone, validToken) => {
|
||||
await appApi.sendSmsCode(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
phone: requestedPhone,
|
||||
...(validToken ? { validToken } : {}),
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
sentPhone.value = requestedPhone;
|
||||
smsCooldown.start();
|
||||
showToast("验证码已发送");
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
if (phoneState.value !== "ready" || cooldownSeconds.value > 0) return;
|
||||
if (!isAuthPhone(phone.value)) {
|
||||
@@ -185,79 +181,7 @@ const prepareGetCode = async () => {
|
||||
return;
|
||||
}
|
||||
errors.phone = "";
|
||||
phoneState.value = "sending";
|
||||
try {
|
||||
const requestedPhone = phone.value;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
{
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
subject: requestedPhone,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone) {
|
||||
throw new Error("手机号已变化,请重新获取验证码");
|
||||
}
|
||||
const requirement = normalizeCaptchaRequirement(response);
|
||||
if (!requirement.required) {
|
||||
await sendSmsCode(requestedPhone);
|
||||
return;
|
||||
}
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `m05-phone-change-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
operationCode: AUTH_VERIFICATION_OPERATION.PHONE_CHANGE,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "安全验证暂不可用");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const context = tacContext.value;
|
||||
if (!context || phoneState.value !== "ready") return;
|
||||
phoneState.value = "sending";
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, context.requestId);
|
||||
if (phone.value !== context.subject) {
|
||||
throw new Error("手机号已变化,请重新验证");
|
||||
}
|
||||
closeTac();
|
||||
await sendSmsCode(context.subject, ticket.validToken);
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = context.subject;
|
||||
smsCooldown.start();
|
||||
showToast("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showToast(error?.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
}
|
||||
};
|
||||
|
||||
const handleTacFailure = ({ message } = {}) => {
|
||||
showToast(message || "行为验证未通过,请重试");
|
||||
};
|
||||
|
||||
const handleTacError = ({ message } = {}) => {
|
||||
closeTac();
|
||||
showToast(message || "安全验证暂不可用");
|
||||
return smsVerification.requestCode();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
@@ -273,24 +197,34 @@ const validateForm = () => {
|
||||
|
||||
const submitPhoneChange = async () => {
|
||||
if (phoneState.value !== "ready" || !validateForm()) return;
|
||||
phoneState.value = "submitting";
|
||||
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
|
||||
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
|
||||
if (phoneChangeAttempt === null) {
|
||||
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
return;
|
||||
}
|
||||
submittingPhoneChange.value = true;
|
||||
try {
|
||||
await appApi.changePhone(
|
||||
{ phone: phone.value, smsCode: smsCode.value },
|
||||
{ requestController },
|
||||
await authApi.changePhone(
|
||||
phoneChangePayload,
|
||||
{ requestController: phoneChangeController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!isPageActive) return;
|
||||
phone.value = "";
|
||||
smsCode.value = "";
|
||||
sentPhone.value = "";
|
||||
baseline.value = formSnapshot.value;
|
||||
showToast("手机号换绑成功");
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showToast(error?.message || "换绑结果未知,请重新登录确认手机号");
|
||||
if (!isPageActive) return;
|
||||
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
|
||||
showToast("换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
showToast(error?.code ? getRequestErrorMessage(error, "换绑未完成,请稍后重试") : error?.message || "换绑未完成,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) phoneState.value = "ready";
|
||||
if (isPageActive) submittingPhoneChange.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,19 +234,16 @@ const requestBack = () =>
|
||||
dirty: isDirty.value,
|
||||
submitting: phoneState.value !== "ready",
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
requestController.abort();
|
||||
return true;
|
||||
},
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onShow(() => smsCooldown.sync());
|
||||
onShow(() => smsVerification.syncCooldown());
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
smsCooldown.dispose();
|
||||
isPageActive = false;
|
||||
smsVerification.dispose();
|
||||
phoneChangeController.abort();
|
||||
discardConfirmation.dispose();
|
||||
clearTimeout(toastTimer);
|
||||
});
|
||||
@@ -388,6 +319,9 @@ onUnload(() => {
|
||||
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;
|
||||
@@ -401,7 +335,7 @@ onUnload(() => {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.code-action::after {
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<view class="document-page" :class="`document-state--${complianceDocumentState}`">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer">
|
||||
<PageHeader :title="fallbackTitle" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view class="document-content page-layer">
|
||||
<AppLoading
|
||||
v-if="complianceDocumentState === 'loading'"
|
||||
text="正在读取内容"
|
||||
description="请稍候。"
|
||||
/>
|
||||
|
||||
<view v-else-if="complianceDocumentState === 'error'" class="document-state-card">
|
||||
<text>暂时无法读取内容</text>
|
||||
<text>{{ errorMessage }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadDocument" />
|
||||
</view>
|
||||
|
||||
<view v-else class="document-sheet">
|
||||
<view class="document-heading">
|
||||
<text>{{ document.title }}</text>
|
||||
<text>版本 {{ document.versionNo }}</text>
|
||||
<text v-if="document.publishedAt">发布日期:{{ displayDate(document.publishedAt) }}</text>
|
||||
<text v-if="document.effectiveAt">生效日期:{{ displayDate(document.effectiveAt) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="document-body">
|
||||
<template v-for="(block, index) in contentBlocks" :key="`${block.type}-${index}`">
|
||||
<text v-if="block.type === 'title'" class="content-title">{{ block.text }}</text>
|
||||
<text v-else-if="block.type === 'heading'" class="content-heading">{{ block.text }}</text>
|
||||
<view v-else-if="block.type === 'list-item'" class="content-list-item">
|
||||
<text>{{ block.marker }}</text>
|
||||
<text>{{ block.text }}</text>
|
||||
</view>
|
||||
<text v-else class="content-paragraph">{{ block.text }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import {
|
||||
COMPLIANCE_DOCUMENT_KEY
|
||||
} from "@/services/api/site-content-contract.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatComplianceContent } from "@/utils/profile/compliance-content.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const documentKey = ref("");
|
||||
const complianceDocumentState = ref("loading");
|
||||
const errorMessage = ref("");
|
||||
const document = reactive({
|
||||
title: "",
|
||||
versionNo: "",
|
||||
content: "",
|
||||
publishedAt: "",
|
||||
effectiveAt: "",
|
||||
});
|
||||
const complianceDocumentRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const fallbackTitle = computed(() =>
|
||||
documentKey.value === COMPLIANCE_DOCUMENT_KEY.PRIVACY_POLICY
|
||||
? "隐私政策"
|
||||
: "用户协议",
|
||||
);
|
||||
const contentBlocks = computed(() =>
|
||||
formatComplianceContent(document.content, document.title),
|
||||
);
|
||||
const displayDate = (value) => String(value || "").slice(0, 10);
|
||||
const loadDocument = async () => {
|
||||
if (!Object.values(COMPLIANCE_DOCUMENT_KEY).includes(documentKey.value)) {
|
||||
errorMessage.value = "文档类型不正确,请返回后重新进入。";
|
||||
complianceDocumentState.value = "error";
|
||||
return;
|
||||
}
|
||||
complianceDocumentRequestController.abort();
|
||||
complianceDocumentState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const complianceDocument = await siteContentApi.getComplianceDocument(documentKey.value, {
|
||||
requestController: complianceDocumentRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
Object.assign(document, complianceDocument);
|
||||
complianceDocumentState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
errorMessage.value = getRequestErrorMessage(error, "请检查网络后重新加载。");
|
||||
complianceDocumentState.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => goBack();
|
||||
|
||||
onLoad((options = {}) => {
|
||||
documentKey.value = String(options.documentKey || "");
|
||||
loadDocument();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
complianceDocumentRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.document-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.document-sheet,
|
||||
.document-state-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.document-sheet {
|
||||
padding: 34rpx 36rpx 48rpx;
|
||||
background-color: rgba(255, 252, 245, 0.9);
|
||||
}
|
||||
.document-heading {
|
||||
padding-bottom: 24rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.document-heading text {
|
||||
display: block;
|
||||
}
|
||||
.document-heading text:first-child {
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(21px, 38rpx, 27px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.document-heading text:not(:first-child) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.document-body {
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.content-title,
|
||||
.content-heading,
|
||||
.content-paragraph {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.content-title {
|
||||
color: $ink;
|
||||
font-size: clamp(18px, 31rpx, 23px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.content-heading {
|
||||
margin-top: 30rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.content-paragraph,
|
||||
.content-list-item {
|
||||
margin-top: 18rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.content-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
.content-list-item text:last-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.document-state-card {
|
||||
min-height: 320rpx;
|
||||
padding: 58rpx 42rpx 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.document-state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.document-state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 33rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.document-state-card > text:nth-child(2) {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.document-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<view class="earnings-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="收益与提现" /></view>
|
||||
<view class="earnings-content page-layer">
|
||||
<AppLoading
|
||||
v-if="earningsState === 'loading'"
|
||||
text="正在读取收益"
|
||||
description="请稍候。"
|
||||
/>
|
||||
<view v-else-if="earningsState === 'error'" class="state-card">
|
||||
<text>收益暂时无法读取</text>
|
||||
<text>{{ pageError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadAll" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<text v-if="operationNotice" class="operation-notice" role="status">{{ operationNotice }}</text>
|
||||
<view class="summary-panel">
|
||||
<text>可提现收益</text>
|
||||
<text>¥{{ summary.availableAmount }}</text>
|
||||
<view class="summary-row">
|
||||
<text>处理中</text>
|
||||
<text>¥{{ summary.frozenAmount }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>最低提现金额</text>
|
||||
<text>{{ minimumWithdrawalLabel }}</text>
|
||||
</view>
|
||||
<text v-if="!summary.withdrawalEnabled" class="summary-note">当前暂未开放提现,可以先查看收益明细。</text>
|
||||
<AppButton
|
||||
v-else
|
||||
block
|
||||
:disabled="!canStartWithdrawal"
|
||||
:label="canStartWithdrawal ? '申请提现' : '暂未达到最低提现金额'"
|
||||
@click="openWithdrawalForm"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="record-tabs" role="tablist" aria-label="收益记录分类">
|
||||
<button
|
||||
:class="{ active: activeTab === 'ledger' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'ledger'"
|
||||
@click="activeTab = 'ledger'"
|
||||
>
|
||||
收益明细
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'withdrawals' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'withdrawals'"
|
||||
@click="activeTab = 'withdrawals'"
|
||||
>
|
||||
提现记录
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'ledger'" class="record-list">
|
||||
<text v-if="!ledgerRows.length" class="empty-copy">还没有收益明细</text>
|
||||
<view
|
||||
v-for="ledgerEntry in ledgerRows"
|
||||
:key="ledgerEntry.ledgerId"
|
||||
class="record-card"
|
||||
>
|
||||
<view>
|
||||
<text>{{ earningTypeLabel(ledgerEntry.entryType) }}</text>
|
||||
<text>{{ recordTimeLabel(ledgerEntry.createTime) }}</text>
|
||||
</view>
|
||||
<text :class="{ 'amount-negative': ledgerEntry.availableDelta.startsWith('-') }">
|
||||
{{ signedMoney(ledgerEntry.availableDelta) }}
|
||||
</text>
|
||||
<text v-if="ledgerEntry.remark">{{ ledgerEntry.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="!withdrawalRows.length" class="empty-copy">还没有提现记录</text>
|
||||
<view
|
||||
v-for="withdrawal in withdrawalRows"
|
||||
:key="withdrawal.withdrawalId"
|
||||
class="record-card"
|
||||
>
|
||||
<view>
|
||||
<text>提现 ¥{{ withdrawal.amount }}</text>
|
||||
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
|
||||
</view>
|
||||
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
|
||||
<text>收款人:{{ withdrawal.payoutAccountName || '未填写' }}</text>
|
||||
<text v-if="withdrawal.failureReason">原因:{{ withdrawal.failureReason }}</text>
|
||||
<AppButton
|
||||
v-if="withdrawal.withdrawalStatus === 'PENDING'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="取消提现"
|
||||
@click="requestCancellation(withdrawal)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<EarningWithdrawalDialog
|
||||
ref="earningWithdrawalDialog"
|
||||
:available-amount="summary.availableAmount"
|
||||
:minimum-withdrawal="summary.minimumWithdrawal"
|
||||
:after-submitted="handleWithdrawalSubmitted"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(cancelTarget)"
|
||||
title="取消这笔提现吗?"
|
||||
:message="cancelTarget ? `将取消 ¥${cancelTarget.amount} 的提现申请,并由后端解冻对应收益。` : ''"
|
||||
:confirm-text="cancelling ? '正在取消' : '确认取消'"
|
||||
cancel-text="保留申请"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="cancelWithdrawal"
|
||||
@cancel="cancelTarget = null"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import EarningWithdrawalDialog from "@/components/profile/EarningWithdrawalDialog.vue";
|
||||
import {
|
||||
EARNING_WITHDRAWAL_STATUS_LABELS as WITHDRAWAL_STATUS_LABELS
|
||||
} from "@/services/api/earning-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { earningApi } from "@/services/api/earning-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import {
|
||||
formatSignedMoney,
|
||||
parseMoneyToCents,
|
||||
} from "@/utils/profile/earning-money.js";
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
const EARNING_TYPE_LABELS = Object.freeze({
|
||||
PAYMENT_REWARD: "邀请家人奖励",
|
||||
PAYMENT_REFUND_REVERSAL: "邀请奖励退款扣回",
|
||||
WITHDRAWAL_FREEZE: "提现金额冻结",
|
||||
WITHDRAWAL_CANCEL: "取消提现退回",
|
||||
WITHDRAWAL_REJECT: "提现未通过退回",
|
||||
WITHDRAWAL_PAID: "提现已到账",
|
||||
WITHDRAWAL_FAILED: "提现失败退回",
|
||||
});
|
||||
const earningsState = ref("loading");
|
||||
const pageError = ref("");
|
||||
const operationNotice = ref("");
|
||||
const activeTab = ref("ledger");
|
||||
const summary = reactive({
|
||||
availableAmount: "0.00",
|
||||
frozenAmount: "0.00",
|
||||
minimumWithdrawal: null,
|
||||
rewardRateBps: null,
|
||||
withdrawalEnabled: false,
|
||||
});
|
||||
const ledgerRows = ref([]);
|
||||
const withdrawalRows = ref([]);
|
||||
const earningWithdrawalDialog = ref(null);
|
||||
const cancelTarget = ref(null);
|
||||
const cancelling = ref(false);
|
||||
const summaryController = createRequestController();
|
||||
const ledgerController = createRequestController();
|
||||
const withdrawalListController = createRequestController();
|
||||
const withdrawalCancellationController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const canStartWithdrawal = computed(() => {
|
||||
const available = parseMoneyToCents(summary.availableAmount);
|
||||
const minimum = parseMoneyToCents(summary.minimumWithdrawal || "0.01");
|
||||
return summary.withdrawalEnabled && available !== null && minimum !== null && available >= minimum;
|
||||
});
|
||||
const minimumWithdrawalLabel = computed(() =>
|
||||
summary.minimumWithdrawal
|
||||
? `¥${summary.minimumWithdrawal}`
|
||||
: "以提交时提示为准",
|
||||
);
|
||||
const signedMoney = formatSignedMoney;
|
||||
const recordTimeLabel = (value) =>
|
||||
formatMinuteTimestamp(value) || "未标注时间";
|
||||
const earningTypeLabel = (entryType) =>
|
||||
EARNING_TYPE_LABELS[entryType] || "收益变动";
|
||||
const withdrawalStatusLabel = (withdrawalStatus) =>
|
||||
WITHDRAWAL_STATUS_LABELS[withdrawalStatus] || "处理中";
|
||||
|
||||
const loadAll = async () => {
|
||||
summaryController.abort();
|
||||
ledgerController.abort();
|
||||
withdrawalListController.abort();
|
||||
earningsState.value = "loading";
|
||||
pageError.value = "";
|
||||
try {
|
||||
const [nextSummary, ledger, withdrawals] = await Promise.all([
|
||||
earningApi.getEarningSummary({ requestController: summaryController }),
|
||||
earningApi.getEarningLedgerPage({ pageNum: 1, pageSize: 50 }, { requestController: ledgerController }),
|
||||
earningApi.getEarningWithdrawalPage(
|
||||
{ pageNum: 1, pageSize: 50 },
|
||||
{ requestController: withdrawalListController },
|
||||
),
|
||||
]);
|
||||
if (!isPageActive) return;
|
||||
Object.assign(summary, nextSummary);
|
||||
ledgerRows.value = ledger.rows;
|
||||
withdrawalRows.value = withdrawals.rows;
|
||||
earningsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
earningsState.value = "error";
|
||||
pageError.value = getRequestErrorMessage(error, "请检查网络后重新读取。");
|
||||
}
|
||||
};
|
||||
const openWithdrawalForm = () => {
|
||||
if (!canStartWithdrawal.value) return;
|
||||
earningWithdrawalDialog.value?.open();
|
||||
};
|
||||
const handleWithdrawalSubmitted = async () => {
|
||||
operationNotice.value = "提现申请已提交,可以在提现记录中查看进度。";
|
||||
await loadAll();
|
||||
};
|
||||
const requestCancellation = (withdrawal) => {
|
||||
cancelTarget.value = withdrawal;
|
||||
};
|
||||
const cancelWithdrawal = async () => {
|
||||
if (!cancelTarget.value || cancelling.value) return;
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await earningApi.cancelEarningWithdrawal(cancelTarget.value.withdrawalId, {
|
||||
requestController: withdrawalCancellationController,
|
||||
});
|
||||
cancelTarget.value = null;
|
||||
operationNotice.value = "提现申请已取消,对应收益将按后端结果解冻。";
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (isWriteOutcomeUnknown(error)) {
|
||||
cancelTarget.value = null;
|
||||
operationNotice.value =
|
||||
"暂时无法确认是否取消成功,已重新读取提现记录,请勿立即重复操作。";
|
||||
await loadAll();
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
operationNotice.value = getRequestErrorMessage(
|
||||
error,
|
||||
"暂时无法取消,请稍后重试。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) cancelling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
void loadAll();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
summaryController.abort();
|
||||
ledgerController.abort();
|
||||
withdrawalListController.abort();
|
||||
withdrawalCancellationController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.earnings-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 48rpx;
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.earnings-content {
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
}
|
||||
|
||||
.operation-notice {
|
||||
display: block;
|
||||
margin-bottom: 14rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||
background: rgba(255, 249, 231, 0.94);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-panel > text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-panel > text:nth-child(2) {
|
||||
margin: 10rpx 0 22rpx;
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(30px, 54rpx, 40px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 10rpx 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
margin: 18rpx 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-panel .app-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.record-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 22rpx;
|
||||
border-bottom: 2rpx solid rgba(159, 35, 35, 0.18);
|
||||
}
|
||||
|
||||
.record-tabs button {
|
||||
min-height: 76rpx;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
}
|
||||
|
||||
.record-tabs button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.record-tabs button.active {
|
||||
border-bottom: 4rpx solid $brand-red;
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: 14rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.record-card > view:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.record-card text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.record-card > view:first-child text:first-child,
|
||||
.record-card > text:nth-child(2) {
|
||||
color: $ink;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-card > text:nth-child(2) {
|
||||
margin-top: 8rpx;
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.record-card .amount-negative {
|
||||
color: $ink-muted;
|
||||
}
|
||||
|
||||
.record-card .app-button {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
display: block;
|
||||
padding: 70rpx 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 70rpx 38rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card text {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-02;用途:编辑已由 Apifox 声明的个人资料字段。 -->
|
||||
<template>
|
||||
<view class="profile-edit-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
@@ -9,9 +8,9 @@
|
||||
<view v-if="loading" class="state-card"
|
||||
><text>正在读取个人资料…</text></view
|
||||
>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<view v-else-if="profileLoadError" class="state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<text>{{ profileLoadError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<template v-else>
|
||||
@@ -98,7 +97,7 @@
|
||||
/></view>
|
||||
</view>
|
||||
<text class="form-note"
|
||||
>资料字段均可按需填写;留空不会提交为修改。</text
|
||||
>资料可按需填写;留空内容不会修改。</text
|
||||
>
|
||||
<text v-if="saveError" class="save-error">{{ saveError }}</text>
|
||||
<AppButton
|
||||
@@ -122,16 +121,19 @@ import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
PROFILE_SEX_OPTIONS
|
||||
} from "@/services/api/profile-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
toConsumerOssId,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
nickName: "",
|
||||
@@ -152,22 +154,23 @@ const avatarId = ref(null);
|
||||
const avatarFileName = ref("");
|
||||
const avatarPreviewUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const profileLoadError = ref("");
|
||||
const uploading = ref(false);
|
||||
const avatarError = ref("");
|
||||
const saving = ref(false);
|
||||
const saveError = ref("");
|
||||
const committedProfilePayload = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const requestController = createRequestController();
|
||||
const profileReadController = createRequestController();
|
||||
const avatarUploadController = createRequestController();
|
||||
const profileSaveController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
const sexOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
{ value: "0", label: "男" },
|
||||
{ value: "1", label: "女" },
|
||||
{ value: "2", label: "未知" },
|
||||
];
|
||||
let isPageActive = true;
|
||||
const sexOptions = Object.freeze([
|
||||
Object.freeze({ value: "", label: "请选择" }),
|
||||
...PROFILE_SEX_OPTIONS,
|
||||
]);
|
||||
|
||||
const sexIndex = computed(() => {
|
||||
const index = sexOptions.findIndex((option) => option.value === form.sex);
|
||||
@@ -176,7 +179,7 @@ const sexIndex = computed(() => {
|
||||
|
||||
const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
return avatarId.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
|
||||
});
|
||||
|
||||
const showFeedback = (message) => {
|
||||
@@ -195,10 +198,10 @@ const toBirthdayDate = (value) =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value === false && (uploading.value || saving.value)) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
profileLoadError.value = "";
|
||||
try {
|
||||
const profile = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
const profile = await profileApi.getProfile({ requestController: profileReadController });
|
||||
if (!isPageActive) return;
|
||||
Object.assign(form, {
|
||||
nickName: profile.nickName,
|
||||
realName: profile.realName,
|
||||
@@ -206,15 +209,19 @@ const loadProfile = async () => {
|
||||
birthday: toBirthdayDate(profile.birthday),
|
||||
email: profile.email,
|
||||
});
|
||||
Object.assign(original, { ...form, avatar: profile.avatar });
|
||||
avatarId.value = profile.avatar;
|
||||
Object.assign(original, {
|
||||
...form,
|
||||
avatar: profile.avatarFile?.ossId || null,
|
||||
});
|
||||
committedProfilePayload.value = "";
|
||||
avatarId.value = null;
|
||||
avatarFileName.value = "";
|
||||
avatarPreviewUrl.value = "";
|
||||
avatarPreviewUrl.value = profile.avatarFile?.accessUrl || "";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error))
|
||||
profileLoadError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
if (isPageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,35 +238,40 @@ const uploadAvatar = async () => {
|
||||
uploading.value = true;
|
||||
avatarError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
if (!pageActive) return;
|
||||
avatarId.value = toConsumerOssId(receipt.ossId);
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: avatarUploadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
avatarId.value = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "新头像";
|
||||
avatarPreviewUrl.value = receipt.thumbnailUrl || receipt.url || "";
|
||||
} catch (error) {
|
||||
if (
|
||||
pageActive &&
|
||||
isPageActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
avatarError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdatePayload = () => {
|
||||
const data = {};
|
||||
const profileChanges = {};
|
||||
for (const field of ["nickName", "realName", "sex", "email"]) {
|
||||
if (form[field] && form[field] !== original[field])
|
||||
data[field] = form[field];
|
||||
profileChanges[field] = form[field];
|
||||
}
|
||||
if (form.birthday && form.birthday !== original.birthday)
|
||||
data.birthday = form.birthday;
|
||||
if (avatarId.value && avatarId.value !== original.avatar)
|
||||
data.avatar = avatarId.value;
|
||||
return data;
|
||||
profileChanges.birthday = form.birthday;
|
||||
if (
|
||||
avatarId.value &&
|
||||
String(avatarId.value) !== String(original.avatar || "")
|
||||
)
|
||||
profileChanges.avatar = avatarId.value;
|
||||
return profileChanges;
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
@@ -271,19 +283,27 @@ const saveProfile = async () => {
|
||||
}
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
const payloadFingerprint = JSON.stringify(payload);
|
||||
try {
|
||||
await appApi.updateProfile(payload, { requestController });
|
||||
if (!pageActive) return;
|
||||
if (committedProfilePayload.value !== payloadFingerprint) {
|
||||
await profileApi.updateProfile(payload, {
|
||||
requestController: profileSaveController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
committedProfilePayload.value = payloadFingerprint;
|
||||
}
|
||||
await finishPage(
|
||||
"M01",
|
||||
{},
|
||||
{ operation: "profile-updated", refresh: true },
|
||||
);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
saveError.value = error?.message || "保存失败,请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error))
|
||||
saveError.value = committedProfilePayload.value === payloadFingerprint
|
||||
? "资料已经保存,但页面返回失败。请再次点击保存重试返回。"
|
||||
: getRequestErrorMessage(error, "保存失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) saving.value = false;
|
||||
if (isPageActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,8 +314,10 @@ const backToProfile = () => {
|
||||
|
||||
onLoad(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
isPageActive = false;
|
||||
profileReadController.abort();
|
||||
avatarUploadController.abort();
|
||||
profileSaveController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
});
|
||||
</script>
|
||||
@@ -356,6 +378,9 @@ onUnload(() => {
|
||||
gap: 16rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.avatar-row {
|
||||
grid-template-columns: minmax(0, 1fr) 180rpx;
|
||||
}
|
||||
.avatar-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -477,5 +502,8 @@ onUnload(() => {
|
||||
grid-template-columns: 116rpx minmax(0, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
.avatar-row {
|
||||
grid-template-columns: minmax(0, 1fr) 160rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-07;用途:提交意见反馈。 -->
|
||||
<template>
|
||||
<view
|
||||
class="feedback-page"
|
||||
@@ -27,19 +26,20 @@
|
||||
>
|
||||
<view class="type-grid">
|
||||
<button
|
||||
v-for="type in feedbackTypes"
|
||||
:key="type.value"
|
||||
v-for="feedbackType in feedbackTypes"
|
||||
:key="feedbackType.value"
|
||||
class="type-chip"
|
||||
:class="{ active: feedbackForm.feedbackType === type.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type.value"
|
||||
:class="{ active: feedbackForm.feedbackType === feedbackType.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === feedbackType.value"
|
||||
:disabled="feedbackState === 'submitting'"
|
||||
@click="selectFeedbackType(type.value)"
|
||||
@click="selectFeedbackType(feedbackType.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
{{ feedbackType.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-panel">
|
||||
<text class="form-label">问题描述(必填)</text>
|
||||
<textarea
|
||||
v-model.trim="feedbackForm.feedbackContent"
|
||||
auto-height
|
||||
@@ -100,6 +100,54 @@
|
||||
:label="submitLabel"
|
||||
@click="submitFeedback"
|
||||
/>
|
||||
<view class="history-section">
|
||||
<view class="history-heading">
|
||||
<text class="history-title">我的反馈</text>
|
||||
<text class="history-note">这里会显示处理进度和回复</text>
|
||||
</view>
|
||||
<view v-if="historyState === 'loading'" class="history-state" role="status">
|
||||
正在读取反馈记录…
|
||||
</view>
|
||||
<view v-else-if="historyState === 'empty'" class="history-state">
|
||||
还没有提交过反馈
|
||||
</view>
|
||||
<view v-else-if="historyState === 'error'" class="history-state history-state--error" role="alert">
|
||||
<text>{{ historyError }}</text>
|
||||
<button class="history-retry" @click="loadFeedbackHistory">重新加载</button>
|
||||
</view>
|
||||
<view v-else class="history-list">
|
||||
<view
|
||||
v-for="feedback in feedbackHistory"
|
||||
:key="String(feedback.feedbackId)"
|
||||
class="history-card"
|
||||
>
|
||||
<view class="history-card__meta">
|
||||
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
|
||||
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
|
||||
</view>
|
||||
<text
|
||||
class="history-content"
|
||||
:class="{
|
||||
'history-content--collapsed':
|
||||
isFeedbackLong(feedback) && !isFeedbackExpanded(feedback),
|
||||
}"
|
||||
>{{ feedback.feedbackContent }}</text
|
||||
>
|
||||
<button
|
||||
v-if="isFeedbackLong(feedback)"
|
||||
class="history-expand"
|
||||
:aria-expanded="isFeedbackExpanded(feedback)"
|
||||
@click="toggleFeedbackExpanded(feedback)"
|
||||
>
|
||||
{{ isFeedbackExpanded(feedback) ? "收起" : "展开全文" }}
|
||||
</button>
|
||||
<view v-if="feedback.handleResult" class="history-reply">
|
||||
<text class="history-reply__label">处理回复</text>
|
||||
<text>{{ feedback.handleResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
@@ -117,26 +165,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { nextTick, reactive, ref, watch } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
FEEDBACK_TYPE_OPTIONS as feedbackTypes
|
||||
} from "@/services/api/feedback-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { feedbackApi } from "@/services/api/feedback-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createFeedbackSubmissionSession } from "@/utils/profile/feedback-submission.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const feedbackTypes = [
|
||||
{ value: "bug", label: "功能问题" },
|
||||
{ value: "advice", label: "使用建议" },
|
||||
{ value: "complaint", label: "投诉反馈" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
const feedbackStatusLabels = {
|
||||
0: "待处理",
|
||||
1: "处理中",
|
||||
2: "已处理",
|
||||
3: "暂不处理",
|
||||
};
|
||||
const feedbackTypeLabel = (feedbackTypeValue) =>
|
||||
feedbackTypes.find(
|
||||
(feedbackType) => feedbackType.value === feedbackTypeValue,
|
||||
)?.label || "其他";
|
||||
const feedbackStatusLabel = (handleStatus) =>
|
||||
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
|
||||
const feedbackForm = reactive({
|
||||
feedbackType: "",
|
||||
feedbackContent: "",
|
||||
@@ -146,35 +205,62 @@ const errors = reactive({ feedbackContent: "" });
|
||||
const feedbackState = ref("ready");
|
||||
const feedbackResult = ref("");
|
||||
const feedbackResultTone = ref("");
|
||||
const isDirty = ref(false);
|
||||
const submitDisabled = ref(false);
|
||||
const submitLabel = ref("提交反馈");
|
||||
const feedbackContentFocused = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const lastSubmittedSnapshot = ref("");
|
||||
const lastUncertainSnapshot = ref("");
|
||||
const feedbackHistory = ref([]);
|
||||
const historyState = ref("loading");
|
||||
const historyError = ref("");
|
||||
const expandedFeedbackIds = ref(new Set());
|
||||
const feedbackIdentity = (feedback) => String(feedback?.feedbackId || "");
|
||||
const feedbackReference = (feedback) => {
|
||||
const feedbackId = feedbackIdentity(feedback);
|
||||
return feedbackId ? `反馈编号 ${feedbackId.slice(-8)}` : "反馈记录";
|
||||
};
|
||||
const isFeedbackLong = (feedback) =>
|
||||
String(feedback?.feedbackContent || "").length > 120;
|
||||
const isFeedbackExpanded = (feedback) =>
|
||||
expandedFeedbackIds.value.has(feedbackIdentity(feedback));
|
||||
const toggleFeedbackExpanded = (feedback) => {
|
||||
const feedbackId = feedbackIdentity(feedback);
|
||||
if (!feedbackId) return;
|
||||
const nextExpandedIds = new Set(expandedFeedbackIds.value);
|
||||
if (nextExpandedIds.has(feedbackId)) nextExpandedIds.delete(feedbackId);
|
||||
else nextExpandedIds.add(feedbackId);
|
||||
expandedFeedbackIds.value = nextExpandedIds;
|
||||
};
|
||||
let pageActive = true;
|
||||
const requestController = createRequestController();
|
||||
const normalizedForm = computed(() => ({
|
||||
feedbackType: feedbackForm.feedbackType.trim(),
|
||||
feedbackContent: feedbackForm.feedbackContent.trim(),
|
||||
contactInfo: feedbackForm.contactInfo.trim(),
|
||||
}));
|
||||
const formSnapshot = computed(() => JSON.stringify(normalizedForm.value));
|
||||
const baseline = ref(formSnapshot.value);
|
||||
const isDirty = computed(() => formSnapshot.value !== baseline.value);
|
||||
const submitDisabled = computed(
|
||||
() =>
|
||||
feedbackState.value === "submitting" ||
|
||||
(Boolean(lastSubmittedSnapshot.value) &&
|
||||
formSnapshot.value === lastSubmittedSnapshot.value) ||
|
||||
(Boolean(lastUncertainSnapshot.value) &&
|
||||
formSnapshot.value === lastUncertainSnapshot.value),
|
||||
);
|
||||
const submitLabel = computed(() => {
|
||||
if (feedbackState.value === "submitting") return "正在提交";
|
||||
if (feedbackState.value === "success") return "反馈已提交";
|
||||
if (feedbackState.value === "uncertain") return "提交结果待确认";
|
||||
if (feedbackState.value === "error") return "重新提交";
|
||||
return "提交反馈";
|
||||
});
|
||||
const feedbackHistoryRequestController = createRequestController();
|
||||
const feedbackSubmissionRequestController = createRequestController();
|
||||
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
|
||||
const syncSubmissionView = () => {
|
||||
const submissionView = submissionSession.view(feedbackForm);
|
||||
feedbackState.value = submissionView.phase;
|
||||
feedbackResult.value = submissionView.message;
|
||||
feedbackResultTone.value = submissionView.tone;
|
||||
isDirty.value = submissionView.isDirty;
|
||||
submitDisabled.value = submissionView.isSubmitDisabled;
|
||||
submitLabel.value = submissionView.submitLabel;
|
||||
};
|
||||
const loadFeedbackHistory = async () => {
|
||||
historyState.value = "loading";
|
||||
historyError.value = "";
|
||||
try {
|
||||
const rows = await feedbackApi.getFeedback({ requestController: feedbackHistoryRequestController });
|
||||
if (!pageActive) return;
|
||||
feedbackHistory.value = rows;
|
||||
historyState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
historyState.value = "error";
|
||||
historyError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"暂时无法读取反馈记录,请稍后重试。",
|
||||
);
|
||||
}
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -197,109 +283,38 @@ const selectFeedbackType = (type) => {
|
||||
if (feedbackState.value === "submitting") return;
|
||||
feedbackForm.feedbackType = feedbackForm.feedbackType === type ? "" : type;
|
||||
};
|
||||
const feedbackErrorMessage = (error) => {
|
||||
if (error?.code === "WRITE_UNAVAILABLE") {
|
||||
return "当前为本地预览模式,反馈未提交服务器。";
|
||||
}
|
||||
if (error?.httpStatus === 401) {
|
||||
return "登录状态已失效,服务器未确认提交成功。请重新登录后再处理。";
|
||||
}
|
||||
return error?.message
|
||||
? `服务器未确认提交成功:${error.message}`
|
||||
: "服务器未确认提交成功,请稍后再试。";
|
||||
};
|
||||
const submittedFeedbackMessage =
|
||||
"反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。";
|
||||
const submittedWithPendingEditsMessage = "上一份反馈已提交,当前修改尚未提交。";
|
||||
const uncertainFeedbackMessage =
|
||||
"提交结果可能未知。为避免重复记录,当前内容不能直接重提;请稍后确认或修改后提交新反馈。";
|
||||
const uncertainWithPendingEditsMessage =
|
||||
"上一份反馈的提交结果仍待确认,当前修改尚未提交。";
|
||||
const isFeedbackResultUncertain = (error) => {
|
||||
if (
|
||||
["REQUEST_TIMEOUT", "NETWORK_ERROR", "RESPONSE_INVALID"].includes(
|
||||
error?.code,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error?.code !== "HTTP_ERROR") return false;
|
||||
const status = error.httpStatus;
|
||||
return (status >= 200 && status < 400) || status === 408 || status >= 500;
|
||||
};
|
||||
const submitFeedback = async () => {
|
||||
if (!validateFeedback() || feedbackState.value === "submitting") return;
|
||||
if (
|
||||
(lastSubmittedSnapshot.value &&
|
||||
formSnapshot.value === lastSubmittedSnapshot.value) ||
|
||||
(lastUncertainSnapshot.value &&
|
||||
formSnapshot.value === lastUncertainSnapshot.value)
|
||||
)
|
||||
return;
|
||||
const submittedSnapshot = formSnapshot.value;
|
||||
const submittedPayload = JSON.parse(submittedSnapshot);
|
||||
feedbackState.value = "submitting";
|
||||
feedbackResult.value = "";
|
||||
feedbackResultTone.value = "";
|
||||
if (!validateFeedback()) return;
|
||||
const submission = submissionSession.begin(feedbackForm);
|
||||
if (!submission) return;
|
||||
syncSubmissionView();
|
||||
try {
|
||||
await appApi.submitFeedback(submittedPayload, { requestController });
|
||||
await feedbackApi.submitFeedback(submission.payload, {
|
||||
requestController: feedbackSubmissionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
baseline.value = submittedSnapshot;
|
||||
lastSubmittedSnapshot.value = submittedSnapshot;
|
||||
feedbackResultTone.value = "success";
|
||||
if (formSnapshot.value === submittedSnapshot) {
|
||||
feedbackState.value = "success";
|
||||
feedbackResult.value = submittedFeedbackMessage;
|
||||
} else {
|
||||
feedbackState.value = "ready";
|
||||
feedbackResult.value = submittedWithPendingEditsMessage;
|
||||
}
|
||||
await loadFeedbackHistory();
|
||||
if (!pageActive) return;
|
||||
submissionSession.succeed(submission, feedbackForm);
|
||||
syncSubmissionView();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
if (isFeedbackResultUncertain(error)) {
|
||||
lastUncertainSnapshot.value = submittedSnapshot;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
if (formSnapshot.value === submittedSnapshot) {
|
||||
feedbackState.value = "uncertain";
|
||||
feedbackResult.value = uncertainFeedbackMessage;
|
||||
} else {
|
||||
feedbackState.value = "ready";
|
||||
feedbackResult.value = uncertainWithPendingEditsMessage;
|
||||
}
|
||||
return;
|
||||
}
|
||||
feedbackState.value = "error";
|
||||
feedbackResult.value = feedbackErrorMessage(error);
|
||||
feedbackResultTone.value = "error";
|
||||
submissionSession.fail(submission, error, feedbackForm);
|
||||
syncSubmissionView();
|
||||
}
|
||||
};
|
||||
watch(formSnapshot, (snapshot) => {
|
||||
if (feedbackState.value === "submitting") return;
|
||||
errors.feedbackContent = "";
|
||||
if (lastSubmittedSnapshot.value && snapshot === lastSubmittedSnapshot.value) {
|
||||
feedbackState.value = "success";
|
||||
feedbackResult.value = submittedFeedbackMessage;
|
||||
feedbackResultTone.value = "success";
|
||||
return;
|
||||
}
|
||||
if (lastUncertainSnapshot.value && snapshot === lastUncertainSnapshot.value) {
|
||||
feedbackState.value = "uncertain";
|
||||
feedbackResult.value = uncertainFeedbackMessage;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
return;
|
||||
}
|
||||
feedbackState.value = "ready";
|
||||
if (lastUncertainSnapshot.value) {
|
||||
feedbackResult.value = uncertainWithPendingEditsMessage;
|
||||
feedbackResultTone.value = "uncertain";
|
||||
} else if (lastSubmittedSnapshot.value) {
|
||||
feedbackResult.value = submittedWithPendingEditsMessage;
|
||||
feedbackResultTone.value = "success";
|
||||
} else {
|
||||
feedbackResult.value = "";
|
||||
feedbackResultTone.value = "";
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => [
|
||||
feedbackForm.feedbackType,
|
||||
feedbackForm.feedbackContent,
|
||||
feedbackForm.contactInfo,
|
||||
],
|
||||
() => {
|
||||
errors.feedbackContent = "";
|
||||
submissionSession.reconcile(feedbackForm);
|
||||
syncSubmissionView();
|
||||
},
|
||||
);
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
@@ -310,9 +325,11 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadFeedbackHistory);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
requestController.abort();
|
||||
feedbackHistoryRequestController.abort();
|
||||
feedbackSubmissionRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -413,6 +430,127 @@ onUnload(() => {
|
||||
margin-top: 16rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.38);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-section {
|
||||
margin-top: 30rpx;
|
||||
padding-top: 26rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.42);
|
||||
}
|
||||
.history-heading,
|
||||
.history-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.history-title {
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-note {
|
||||
color: rgba(76, 57, 43, 0.72);
|
||||
font-size: clamp(12px, 19rpx, 14px);
|
||||
}
|
||||
.history-state {
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx 22rpx;
|
||||
border: 1px solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.72);
|
||||
color: rgba(76, 57, 43, 0.78);
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
.history-state--error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.history-retry {
|
||||
min-width: 180rpx;
|
||||
min-height: 72rpx;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 1px solid rgba(164, 41, 36, 0.55);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.history-card {
|
||||
padding: 22rpx;
|
||||
border: 1px solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
box-shadow: 0 8rpx 20rpx rgba(93, 50, 30, 0.06);
|
||||
}
|
||||
.history-card__meta {
|
||||
color: rgba(76, 57, 43, 0.72);
|
||||
font-size: clamp(12px, 19rpx, 14px);
|
||||
}
|
||||
.history-status {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-content {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 16px);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.history-content--collapsed {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
.history-expand {
|
||||
min-height: 72rpx;
|
||||
margin: 4rpx 0 0 auto;
|
||||
padding: 0 8rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.history-expand::after {
|
||||
border: 0;
|
||||
}
|
||||
.history-reply {
|
||||
display: grid;
|
||||
gap: 6rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
border-left: 4rpx solid rgba(181, 137, 63, 0.72);
|
||||
background: rgba(181, 137, 63, 0.08);
|
||||
color: rgba(76, 57, 43, 0.88);
|
||||
font-size: clamp(13px, 20rpx, 15px);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.history-reply__label {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.contact-row text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<view class="help-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="帮助中心" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="help-hero">
|
||||
<text>常见问题</text>
|
||||
<text>帮助内容会随页面更新。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="helpCenterState === 'loading'" class="help-state-card">
|
||||
<AppLoading text="正在读取帮助内容" />
|
||||
</view>
|
||||
<view v-else-if="helpCenterState === 'error'" class="help-state-card">
|
||||
<text>暂时无法读取帮助内容</text>
|
||||
<text>{{ helpCenterError || "请检查网络后重新加载。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadHelpArticles" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="articles.length" class="help-list-panel">
|
||||
<input
|
||||
v-model.trim="searchText"
|
||||
class="help-search"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
placeholder="搜索问题"
|
||||
placeholder-class="help-search__placeholder"
|
||||
/>
|
||||
<scroll-view class="help-category-list" scroll-x>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
class="help-category"
|
||||
:class="{ 'help-category--active': activeCategory === category }"
|
||||
:aria-pressed="activeCategory === category"
|
||||
@click="selectCategory(category)"
|
||||
>{{ categoryLabel(category) }}</button>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="visibleArticles.length" class="help-article-list">
|
||||
<view v-for="item in visibleArticles" :key="item.key" class="help-article">
|
||||
<button
|
||||
class="help-article__question"
|
||||
:aria-expanded="expandedKey === item.key"
|
||||
:aria-controls="`help-answer-${item.key}`"
|
||||
@click="toggleArticle(item.key)"
|
||||
>
|
||||
<view>
|
||||
<text class="help-article__category">{{ item.categoryLabel }}</text>
|
||||
<text class="help-article__title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text class="help-article__indicator">{{ expandedKey === item.key ? "收起" : "查看" }}</text>
|
||||
</button>
|
||||
<text
|
||||
v-if="expandedKey === item.key"
|
||||
:id="`help-answer-${item.key}`"
|
||||
class="help-article__answer"
|
||||
>{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="help-empty-state">
|
||||
<text>没有符合条件的帮助内容</text>
|
||||
<button v-if="searchText || activeCategory !== '全部'" @click="clearFilters">清除筛选</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="help-state-card">
|
||||
<text>当前没有帮助内容</text>
|
||||
<text>如有使用问题,可以直接提交反馈。</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="help-feedback-card">
|
||||
<text>没有找到答案?</text>
|
||||
<text>把问题提交给我们,我们会尽快处理。</text>
|
||||
<AppButton block label="提交问题反馈" @click="contactSupport" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const articles = ref([]);
|
||||
const helpCenterState = ref("loading");
|
||||
const helpCenterError = ref("");
|
||||
const searchText = ref("");
|
||||
const activeCategory = ref("全部");
|
||||
const expandedKey = ref("");
|
||||
const helpArticleRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const categories = computed(() => ["全部", ...new Set(articles.value.map((item) => item.category))]);
|
||||
const categoryLabel = (category) => (
|
||||
articles.value.find((item) => item.category === category)?.categoryLabel || category
|
||||
);
|
||||
const visibleArticles = computed(() => {
|
||||
const keyword = searchText.value.trim().toLocaleLowerCase();
|
||||
return articles.value.filter((item) => (
|
||||
(activeCategory.value === "全部" || item.category === activeCategory.value)
|
||||
&& (!keyword || `${item.title}\n${item.content}`.toLocaleLowerCase().includes(keyword))
|
||||
));
|
||||
});
|
||||
|
||||
const loadHelpArticles = async () => {
|
||||
helpArticleRequestController.abort();
|
||||
helpCenterState.value = "loading";
|
||||
helpCenterError.value = "";
|
||||
expandedKey.value = "";
|
||||
try {
|
||||
const helpArticles = await siteContentApi.getHelpArticles({
|
||||
requestController: helpArticleRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
articles.value = helpArticles;
|
||||
if (!categories.value.includes(activeCategory.value)) activeCategory.value = "全部";
|
||||
helpCenterState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
helpCenterError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
helpCenterState.value = "error";
|
||||
}
|
||||
};
|
||||
const selectCategory = (category) => {
|
||||
activeCategory.value = category;
|
||||
expandedKey.value = "";
|
||||
};
|
||||
const toggleArticle = (key) => {
|
||||
expandedKey.value = expandedKey.value === key ? "" : key;
|
||||
};
|
||||
const clearFilters = () => {
|
||||
searchText.value = "";
|
||||
activeCategory.value = "全部";
|
||||
expandedKey.value = "";
|
||||
};
|
||||
const contactSupport = () => openPage("M07", {}, "M06");
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadHelpArticles);
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
helpArticleRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.help-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.help-hero,
|
||||
.help-state-card,
|
||||
.help-list-panel,
|
||||
.help-feedback-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.help-hero {
|
||||
padding: 34rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-hero text {
|
||||
display: block;
|
||||
}
|
||||
.help-hero text:first-child,
|
||||
.help-state-card text:first-child,
|
||||
.help-feedback-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.help-hero text:last-child,
|
||||
.help-state-card text:last-child,
|
||||
.help-feedback-card text:nth-child(2) {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help-state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.help-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-state-card .app-button,
|
||||
.help-feedback-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.help-list-panel {
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.help-search {
|
||||
width: 100%;
|
||||
min-height: 72rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 24rpx, 18px);
|
||||
}
|
||||
.help-search__placeholder {
|
||||
color: #a49382;
|
||||
}
|
||||
.help-category-list {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-category {
|
||||
display: inline-flex;
|
||||
min-width: 112rpx;
|
||||
min-height: 58rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 29rpx;
|
||||
background: rgba(255, 252, 245, 0.54);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 22rpx, 16px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.help-category::after,
|
||||
.help-article__question::after,
|
||||
.help-empty-state button::after {
|
||||
border: 0;
|
||||
}
|
||||
.help-category--active {
|
||||
border-color: #8d2722;
|
||||
background: #8d2722;
|
||||
color: #fff7e8;
|
||||
}
|
||||
.help-article-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.help-article {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.66);
|
||||
}
|
||||
.help-article__question {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.help-article__question view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.help-article__question text {
|
||||
display: block;
|
||||
}
|
||||
.help-article__category {
|
||||
color: #9a6555;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__title {
|
||||
margin-top: 5rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 19px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.help-article__indicator {
|
||||
flex: 0 0 auto;
|
||||
color: #9f170f;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.help-article__answer {
|
||||
display: block;
|
||||
padding: 0 22rpx 24rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.help-empty-state {
|
||||
padding: 50rpx 22rpx 28rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: center;
|
||||
}
|
||||
.help-empty-state text {
|
||||
display: block;
|
||||
}
|
||||
.help-empty-state button {
|
||||
margin-top: 16rpx;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9f170f;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.help-feedback-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 32rpx 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,9 @@
|
||||
<!-- 页面编号:M-01;用途:个人中心,显示当前用户资料与服务入口。 -->
|
||||
<template>
|
||||
<view
|
||||
class="profile-page"
|
||||
:class="{
|
||||
'profile-state--ready': !loading && !loadError,
|
||||
'profile-state--error': Boolean(loadError),
|
||||
'profile-state--ready': !loading && !profileHomeError,
|
||||
'profile-state--error': Boolean(profileHomeError),
|
||||
}"
|
||||
>
|
||||
<view v-if="compactHeader" class="profile-compact-header" aria-hidden="true">
|
||||
@@ -22,9 +21,9 @@
|
||||
<text>正在读取个人资料…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="profile-state-card">
|
||||
<view v-else-if="profileHomeError" class="profile-state-card">
|
||||
<text>个人资料暂时无法读取</text>
|
||||
<text>{{ loadError }}</text>
|
||||
<text>{{ profileHomeError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
|
||||
@@ -32,7 +31,7 @@
|
||||
<view class="profile-hero">
|
||||
<image
|
||||
class="profile-hero__art"
|
||||
src="/static/assets/modules/profile/opaque/m01-profile-archive-hero-v2.png"
|
||||
src="/static/assets/modules/profile/opaque/archive-hero.png"
|
||||
mode="scaleToFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -54,7 +53,7 @@
|
||||
@click="openSettings"
|
||||
><image
|
||||
class="profile-hero__setting-icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-settings.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-settings.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/></view>
|
||||
@@ -62,7 +61,10 @@
|
||||
|
||||
<view class="profile-hero__identity">
|
||||
<view class="profile-hero__emblem">
|
||||
<AppAvatar :sex="profile.sex" />
|
||||
<AppAvatar
|
||||
:sex="profile.sex"
|
||||
:src="profile.avatarFile?.accessUrl || ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="profile-hero__copy">
|
||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||
@@ -81,7 +83,7 @@
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-person.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-person.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -90,7 +92,7 @@
|
||||
<view class="profile-metadata__item">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-calendar.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-calendar.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -99,7 +101,7 @@
|
||||
<view class="profile-metadata__item profile-metadata__item--email">
|
||||
<image
|
||||
class="profile-metadata__icon"
|
||||
src="/static/assets/modules/profile/transparent/m01-icon-envelope.png"
|
||||
src="/static/assets/modules/profile/transparent/icon-envelope.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -107,6 +109,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-preference" aria-label="个性化推荐设置">
|
||||
<view>
|
||||
<text>个性化推荐</text>
|
||||
<text v-if="preferenceLoading">正在读取设置</text>
|
||||
<text v-else-if="preferenceError">设置暂不可用</text>
|
||||
<text v-else>{{ recommendationPreference.enabled ? "已开启,将优先展示与家谱互动相关的内容" : "已关闭,按常规时间顺序展示内容" }}</text>
|
||||
</view>
|
||||
<switch
|
||||
:checked="recommendationPreference.enabled"
|
||||
:disabled="preferenceLoading || preferenceSaving || Boolean(preferenceError)"
|
||||
color="#9f170f"
|
||||
@change="changeRecommendationPreference"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="profile-services">
|
||||
<view
|
||||
v-for="group in serviceGroups"
|
||||
@@ -130,7 +147,7 @@
|
||||
<image
|
||||
:class="[
|
||||
'profile-service-row__icon',
|
||||
`profile-service-row__icon--${item.routeKey.toLowerCase()}`,
|
||||
`profile-service-row__icon--${item.iconVariant}`,
|
||||
]"
|
||||
:src="item.icon"
|
||||
mode="aspectFit"
|
||||
@@ -150,6 +167,7 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
|
||||
<AppTabbar active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -159,16 +177,21 @@ import { computed, reactive, ref } from "vue";
|
||||
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import {
|
||||
appApi,
|
||||
PROFILE_SEX_OPTIONS
|
||||
} from "@/services/api/profile-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const profile = reactive({
|
||||
avatar: null,
|
||||
avatarFile: null,
|
||||
nickName: "",
|
||||
realName: "",
|
||||
phone: "",
|
||||
@@ -177,10 +200,16 @@ const profile = reactive({
|
||||
email: "",
|
||||
});
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const profileHomeError = ref("");
|
||||
const compactHeader = ref(false);
|
||||
const requestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const profileReadController = createRequestController();
|
||||
const preferenceReadController = createRequestController();
|
||||
const preferenceSaveController = createRequestController();
|
||||
const recommendationPreference = reactive({ enabled: false, version: 0, updatedAt: "" });
|
||||
const preferenceLoading = ref(false);
|
||||
const preferenceSaving = ref(false);
|
||||
const preferenceError = ref("");
|
||||
let isPageActive = true;
|
||||
|
||||
const serviceGroups = [
|
||||
{
|
||||
@@ -189,17 +218,32 @@ const serviceGroups = [
|
||||
{
|
||||
routeKey: "M03",
|
||||
label: "账号与安全",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-security.png",
|
||||
iconVariant: "security",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-security.png",
|
||||
},
|
||||
{
|
||||
routeKey: "N01",
|
||||
label: "消息中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-message.png",
|
||||
iconVariant: "notifications",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M11",
|
||||
label: "活动邀请",
|
||||
iconVariant: "invitations",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-message.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M09",
|
||||
label: "VIP 服务",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-vip.png",
|
||||
iconVariant: "vip",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M12",
|
||||
label: "收益与提现",
|
||||
iconVariant: "earnings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -209,31 +253,37 @@ const serviceGroups = [
|
||||
{
|
||||
routeKey: "M06",
|
||||
label: "帮助中心",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-help.png",
|
||||
iconVariant: "help",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-help.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M07",
|
||||
label: "意见反馈",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-feedback.png",
|
||||
iconVariant: "feedback",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M08",
|
||||
label: "应用推广",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-promotion.png",
|
||||
iconVariant: "promotion",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
|
||||
},
|
||||
{
|
||||
routeKey: "M10",
|
||||
label: "关于与设置",
|
||||
icon: "/static/assets/modules/profile/transparent/m01-icon-settings.png",
|
||||
iconVariant: "settings",
|
||||
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sexText = computed(() => {
|
||||
const labels = { 0: "男", 1: "女", 2: "未知" };
|
||||
return labels[String(profile.sex)] || "未设置";
|
||||
});
|
||||
const profileSexLabels = Object.freeze(
|
||||
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
|
||||
);
|
||||
const sexText = computed(
|
||||
() => profileSexLabels[String(profile.sex)] || "未设置",
|
||||
);
|
||||
const birthdayText = computed(() =>
|
||||
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
|
||||
? profile.birthday.slice(0, 10)
|
||||
@@ -243,17 +293,51 @@ const birthdayText = computed(() =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
profileHomeError.value = "";
|
||||
try {
|
||||
const data = await appApi.getProfile({ requestController });
|
||||
if (!pageActive) return;
|
||||
Object.assign(profile, data);
|
||||
const profileResponse = await profileApi.getProfile({ requestController: profileReadController });
|
||||
if (!isPageActive) return;
|
||||
Object.assign(profile, profileResponse);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
profileHomeError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
if (isPageActive) loading.value = false;
|
||||
}
|
||||
};
|
||||
const loadRecommendationPreference = async () => {
|
||||
if (preferenceLoading.value || preferenceSaving.value) return;
|
||||
preferenceLoading.value = true;
|
||||
preferenceError.value = "";
|
||||
try {
|
||||
const preferenceResponse = await profileApi.getRecommendationPreference({
|
||||
requestController: preferenceReadController,
|
||||
});
|
||||
if (isPageActive) Object.assign(recommendationPreference, preferenceResponse);
|
||||
} catch (error) {
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
preferenceError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) preferenceLoading.value = false;
|
||||
}
|
||||
};
|
||||
const changeRecommendationPreference = async (event) => {
|
||||
if (preferenceLoading.value || preferenceSaving.value || preferenceError.value) return;
|
||||
const enabled = Boolean(event?.detail?.value);
|
||||
preferenceSaving.value = true;
|
||||
try {
|
||||
const savedPreference = await profileApi.updateRecommendationPreference(enabled, {
|
||||
requestController: preferenceSaveController,
|
||||
});
|
||||
if (isPageActive) Object.assign(recommendationPreference, savedPreference);
|
||||
} catch (error) {
|
||||
if (isPageActive && !isRequestCancelled(error)) {
|
||||
preferenceError.value = getRequestErrorMessage(error, "设置未保存,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) preferenceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,14 +345,19 @@ const openService = (item) => openPage(item.routeKey, {}, "M01");
|
||||
const openEdit = () => openPage("M02", {}, "M01");
|
||||
const openSettings = () => openPage("M10", {}, "M01");
|
||||
|
||||
onShow(loadProfile);
|
||||
onShow(() => {
|
||||
void loadProfile();
|
||||
void loadRecommendationPreference();
|
||||
});
|
||||
onPageScroll(({ scrollTop = 0 }) => {
|
||||
compactHeader.value = scrollTop > 44;
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
isPageActive = false;
|
||||
compactHeader.value = false;
|
||||
requestController.abort();
|
||||
profileReadController.abort();
|
||||
preferenceReadController.abort();
|
||||
preferenceSaveController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -282,7 +371,7 @@ onUnload(() => {
|
||||
padding-bottom: 166rpx;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background: #f3eee5 url("/static/assets/modules/profile/opaque/m01-profile-paper-mountains-v1.png") center 360rpx / 100% auto no-repeat;
|
||||
background: #f3eee5 url("/static/assets/modules/profile/opaque/paper-mountains.png") center 360rpx / 100% auto no-repeat;
|
||||
}
|
||||
|
||||
.profile-compact-header {
|
||||
@@ -510,7 +599,7 @@ onUnload(() => {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent url("/static/assets/modules/profile/transparent/m01-profile-edit-button-v2.png") center / 100% 100% no-repeat;
|
||||
background: transparent url("/static/assets/modules/profile/transparent/edit-button.png") center / 100% 100% no-repeat;
|
||||
box-shadow: none;
|
||||
color: #fff6e5;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
@@ -584,6 +673,46 @@ onUnload(() => {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-preference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin: 18rpx 34rpx 0;
|
||||
padding: 22rpx 24rpx;
|
||||
border: 1rpx solid rgba(117, 83, 52, 0.12);
|
||||
border-radius: 18rpx;
|
||||
background: rgba(239, 234, 224, 0.82);
|
||||
}
|
||||
|
||||
.profile-preference > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-preference text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-preference text:first-child {
|
||||
color: #382c25;
|
||||
font-size: clamp(15px, 26rpx, 19px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-preference text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: #857263;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-preference switch {
|
||||
flex: 0 0 auto;
|
||||
transform: scale(0.88);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.profile-services {
|
||||
margin: 49rpx 58rpx 0 48rpx;
|
||||
}
|
||||
@@ -641,31 +770,31 @@ onUnload(() => {
|
||||
transform: translate(7rpx, 0) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m03 {
|
||||
.profile-service-row__icon--security {
|
||||
transform: translate(9rpx, -9rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--n01 {
|
||||
.profile-service-row__icon--notifications {
|
||||
transform: translate(10rpx, -10rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m09 {
|
||||
.profile-service-row__icon--vip {
|
||||
transform: translate(-5rpx, 5rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m06 {
|
||||
.profile-service-row__icon--help {
|
||||
transform: translate(-3rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m07 {
|
||||
.profile-service-row__icon--feedback {
|
||||
transform: translate(4rpx, 8rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m08 {
|
||||
.profile-service-row__icon--promotion {
|
||||
transform: translate(11rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
.profile-service-row__icon--m10 {
|
||||
.profile-service-row__icon--settings {
|
||||
transform: translate(9rpx, 7rpx) scale(1.8);
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
<!-- 页面编号:M-06;用途:帮助搜索、分类与常见问题。 -->
|
||||
<template>
|
||||
<view class="help-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="帮助中心" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view v-if="helpState === 'loading'" class="help-state-card"
|
||||
><AppLoading text="正在读取帮助内容"
|
||||
/></view>
|
||||
<view v-else-if="helpState === 'error'" class="help-state-card">
|
||||
<text>帮助内容暂时无法读取</text>
|
||||
<text>请检查网络后重新加载,或直接提交问题反馈。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadHelpArticles" />
|
||||
<AppButton block label="提交问题反馈" @click="contactSupport" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<text class="help-source-note">{{ sourceNote }}</text>
|
||||
<view class="search-box"
|
||||
><input
|
||||
v-model.trim="keyword"
|
||||
aria-label="搜索帮助"
|
||||
placeholder="搜索问题关键词"
|
||||
/><text>{{ filteredQuestions.length }} 条</text></view
|
||||
>
|
||||
<scroll-view scroll-x class="category-scroll" :show-scrollbar="false"
|
||||
><view class="category-row"
|
||||
><view
|
||||
v-for="category in helpCategories"
|
||||
:key="category.value"
|
||||
class="category-chip"
|
||||
:class="{ active: activeCategory === category.value }"
|
||||
role="button"
|
||||
@click="activeCategory = category.value"
|
||||
>{{ category.label }}</view
|
||||
></view
|
||||
></scroll-view
|
||||
>
|
||||
<view v-if="filteredQuestions.length" class="question-list">
|
||||
<view
|
||||
v-for="item in filteredQuestions"
|
||||
:key="item.id"
|
||||
class="question-card"
|
||||
>
|
||||
<view
|
||||
class="question-heading"
|
||||
role="button"
|
||||
:aria-label="item.question"
|
||||
@click="toggleQuestion(item.id)"
|
||||
><text>{{ item.question }}</text
|
||||
><text>{{
|
||||
expandedIds.includes(item.id) ? "收起" : "查看"
|
||||
}}</text></view
|
||||
>
|
||||
<text v-if="expandedIds.includes(item.id)" class="answer">{{
|
||||
item.answer
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card"
|
||||
><text>没有找到相关问题</text
|
||||
><text>换个关键词试试,或直接提交意见反馈。</text></view
|
||||
>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="联系家谱助手"
|
||||
@click="contactSupport"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
const questions = ref([]);
|
||||
const helpState = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const categoryLabels = Object.freeze({
|
||||
common: "常见问题",
|
||||
member: "成员管理",
|
||||
lineage: "世系关系",
|
||||
});
|
||||
const helpCategories = computed(() => [
|
||||
{ value: "", label: "全部" },
|
||||
...[
|
||||
...new Set(questions.value.map((item) => item.category).filter(Boolean)),
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: categoryLabels[value.toLowerCase()] || value,
|
||||
})),
|
||||
]);
|
||||
const activeCategory = ref("");
|
||||
const keyword = ref("");
|
||||
const expandedIds = ref([]);
|
||||
const sourceNote = computed(
|
||||
() =>
|
||||
({
|
||||
loading: "正在读取帮助文章…",
|
||||
error: "帮助文章暂时无法读取,请稍后重试。",
|
||||
empty: "暂未收到可展示的帮助文章。",
|
||||
ready: "帮助内容来自服务端,可按分类或关键词查找。",
|
||||
})[helpState.value],
|
||||
);
|
||||
const filteredQuestions = computed(() => {
|
||||
const query = keyword.value.toLowerCase();
|
||||
return questions.value.filter(
|
||||
(item) =>
|
||||
(!activeCategory.value || item.category === activeCategory.value) &&
|
||||
(!query ||
|
||||
`${item.question}${item.answer}`.toLowerCase().includes(query)),
|
||||
);
|
||||
});
|
||||
const toggleQuestion = (id) => {
|
||||
expandedIds.value = expandedIds.value.includes(id)
|
||||
? expandedIds.value.filter((item) => item !== id)
|
||||
: [...expandedIds.value, id];
|
||||
};
|
||||
const contactSupport = () => openPage("M07", {}, "M06");
|
||||
const loadHelpArticles = async () => {
|
||||
controller.abort();
|
||||
helpState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getHelpArticles({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
questions.value = rows.map((item) => ({
|
||||
id: String(item.helpId),
|
||||
category: String(item.helpCategory || "其他"),
|
||||
question: String(item.helpTitle || "未命名帮助文章"),
|
||||
answer: String(item.helpContent || "暂无正文"),
|
||||
}));
|
||||
helpState.value = questions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
helpState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad(loadHelpArticles);
|
||||
onShow(() => {
|
||||
if (helpState.value !== "loading") loadHelpArticles();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.help-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 28rpx 72rpx;
|
||||
}
|
||||
.help-source-note {
|
||||
display: block;
|
||||
margin: 0 4rpx 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.help-state-card {
|
||||
display: flex;
|
||||
min-height: 300rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 52rpx 42rpx;
|
||||
text-align: center;
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.help-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.help-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.help-state-card text:nth-child(2) {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help-state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.search-box {
|
||||
@include adaptive-profile-field;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 86rpx;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
.search-box input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 68rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.search-box text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.category-scroll {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.category-row {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
gap: 12rpx;
|
||||
padding: 2rpx;
|
||||
}
|
||||
.category-chip {
|
||||
display: flex;
|
||||
min-width: 100rpx;
|
||||
min-height: 64rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 22rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.category-chip.active {
|
||||
@include adaptive-scroll-button(secondary);
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.question-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.question-card,
|
||||
.empty-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.question-card {
|
||||
min-height: 104rpx;
|
||||
padding: 24rpx 34rpx;
|
||||
}
|
||||
.question-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 58rpx;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.question-heading text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.question-heading text:last-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.answer {
|
||||
display: block;
|
||||
padding: 14rpx 4rpx 6rpx;
|
||||
border-top: 1px solid rgba(181, 137, 63, 0.32);
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.empty-card {
|
||||
min-height: 220rpx;
|
||||
padding: 58rpx 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-card text {
|
||||
display: block;
|
||||
}
|
||||
.empty-card text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-card text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<view class="promotion-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader title="推广中心" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="invite-hero">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>家谱服务推荐</text>
|
||||
<text>以下推荐内容由服务端配置。</text>
|
||||
</view>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取推广内容"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card">
|
||||
<text>暂时无法读取推广内容</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadPromotions"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>当前没有可展示的推广内容</text></view
|
||||
>
|
||||
<view v-else class="promotion-list">
|
||||
<view v-for="item in promotions" :key="item.id" class="promotion-card">
|
||||
<text class="promotion-title">{{ item.title }}</text>
|
||||
<text v-if="item.platform" class="promotion-platform">{{
|
||||
item.platform
|
||||
}}</text>
|
||||
<text v-if="item.description" class="promotion-copy">{{
|
||||
item.description
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const promotions = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const platformLabels = Object.freeze({
|
||||
app: "APP 应用",
|
||||
pc: "管理后台",
|
||||
wechat: "微信小程序",
|
||||
});
|
||||
const formatPlatform = (value) =>
|
||||
platformLabels[
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
] || String(value || "").trim();
|
||||
const loadPromotions = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getPromotions({ requestController: controller });
|
||||
if (!active) return;
|
||||
promotions.value = rows.map((item) => ({
|
||||
id: String(item.promotionId),
|
||||
title: String(item.promotionTitle || "未命名推广"),
|
||||
description: String(item.promotionDesc || ""),
|
||||
platform: formatPlatform(item.platform),
|
||||
}));
|
||||
state.value = promotions.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
onLoad(loadPromotions);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadPromotions();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.promotion-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.invite-hero,
|
||||
.state-card,
|
||||
.promotion-card {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.invite-hero {
|
||||
display: flex;
|
||||
min-height: 250rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 38rpx 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.invite-hero image {
|
||||
width: 90rpx;
|
||||
height: 102rpx;
|
||||
}
|
||||
.invite-hero text {
|
||||
display: block;
|
||||
}
|
||||
.invite-hero text:nth-child(2) {
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invite-hero text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.promotion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.promotion-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 142rpx;
|
||||
flex-direction: column;
|
||||
padding: 30rpx 34rpx;
|
||||
}
|
||||
.promotion-title {
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.promotion-platform {
|
||||
align-self: flex-start;
|
||||
margin-top: 10rpx;
|
||||
padding: 3rpx 12rpx;
|
||||
border: 1rpx solid rgba(181, 46, 34, 0.26);
|
||||
border-radius: 999rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.promotion-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,614 +0,0 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader
|
||||
class="orders-header"
|
||||
title="VIP 与订单"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="vip-intro">
|
||||
<image
|
||||
class="vip-intro__background"
|
||||
src="/static/assets/modules/profile/opaque/m09-vip-hero-landscape.png"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="vip-intro__content">
|
||||
<text class="vip-intro__eyebrow">家谱传承服务</text>
|
||||
<text class="vip-intro__title">开通 VIP,守护家谱长久传承</text>
|
||||
<text class="vip-intro__copy">选择适合家谱规模的服务套餐。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取服务套餐"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取套餐或订单</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadVip"
|
||||
/></view>
|
||||
<template v-else>
|
||||
<view v-if="packages.length" class="package-list"
|
||||
><view
|
||||
v-for="item in packages"
|
||||
:key="item.id"
|
||||
class="package-card"
|
||||
><image
|
||||
class="package-courtyard"
|
||||
src="/static/assets/modules/profile/opaque/m09-featured-courtyard.png"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="package-heading">
|
||||
<view>
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="package-meta"
|
||||
><text v-if="item.price" class="package-price">¥{{ item.price }}</text
|
||||
><text class="package-duration">{{ item.duration || "服务期限以订单为准" }}</text
|
||||
><button
|
||||
class="purchase-button"
|
||||
:disabled="creating"
|
||||
@click="openPurchase(item)"
|
||||
>立即开通</button
|
||||
></view
|
||||
>
|
||||
</view
|
||||
></view
|
||||
>
|
||||
<view v-else class="state-card"
|
||||
><text>当前没有可展示的服务套餐</text></view
|
||||
>
|
||||
<view class="section-heading"
|
||||
><text>订单记录</text><text>{{ orders.length }} 条</text></view
|
||||
>
|
||||
<text v-if="orderNotice" class="order-notice">{{ orderNotice }}</text>
|
||||
<view v-if="orders.length" class="order-list"
|
||||
><view v-for="item in orders" :key="item.id" class="order-card"
|
||||
><view><text>{{ item.title }}</text><text>订单编号已由服务端生成</text></view
|
||||
><text>{{ item.status || "状态待服务端确认" }}</text></view
|
||||
></view
|
||||
>
|
||||
<view v-else class="order-empty"><text>暂无订单记录</text></view>
|
||||
</template>
|
||||
</view>
|
||||
<AppDialog
|
||||
class="payment-sheet"
|
||||
:visible="Boolean(selectedPackage)"
|
||||
title="确认下单"
|
||||
:confirm-text="creating ? '正在创建订单' : '创建订单'"
|
||||
cancel-text="暂不购买"
|
||||
show-cancel
|
||||
:close-on-mask="!creating"
|
||||
@confirm="createOrder"
|
||||
@cancel="closePurchase"
|
||||
>
|
||||
<view class="sheet-order-summary"
|
||||
><view
|
||||
><text>套餐</text><text>{{ selectedPackage?.name }}</text></view
|
||||
><view v-if="selectedPackage?.duration"
|
||||
><text>有效期</text><text>{{ selectedPackage.duration }}</text></view
|
||||
><view><text>应付金额</text><text>¥{{ selectedPackage?.price }}</text></view></view
|
||||
>
|
||||
<view class="payment-method"><text>支付方式</text><text>微信支付</text></view>
|
||||
<text class="payment-copy">将创建微信支付订单,支付完成后请以订单状态为准。</text>
|
||||
<text v-if="purchaseError" class="purchase-error">{{ purchaseError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onReady, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const state = ref("loading");
|
||||
const selectedPackage = ref(null);
|
||||
const creating = ref(false);
|
||||
const purchaseError = ref("");
|
||||
const orderNotice = ref("");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
let statusBarTimer = null;
|
||||
const formatPrice = (value) =>
|
||||
Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "";
|
||||
const durationUnits = Object.freeze({ year: "年", month: "个月", day: "天" });
|
||||
const formatDuration = (value, unit) => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
if (Number.isFinite(Number(value)) && Number(value) <= 0) return "永久有效";
|
||||
const normalizedUnit = String(unit || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return durationUnits[normalizedUnit]
|
||||
? `${value} ${durationUnits[normalizedUnit]}`
|
||||
: String(value);
|
||||
};
|
||||
const loadVip = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const packageRows = await appApi.getVipPackages({
|
||||
requestController: controller,
|
||||
});
|
||||
const orderRows = await appApi.getVipOrders({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
packages.value = packageRows.map((item) => ({
|
||||
id: String(item.packageId),
|
||||
name: String(item.packageName || "未命名套餐"),
|
||||
description: String(item.packageDesc || ""),
|
||||
price: formatPrice(item.price),
|
||||
duration: formatDuration(item.durationValue, item.durationUnit),
|
||||
}));
|
||||
orders.value = orderRows
|
||||
.map((item) => ({
|
||||
id: String(item.orderId || item.id || ""),
|
||||
title: String(item.packageName || item.orderNo || "VIP 订单"),
|
||||
status: String(item.orderStatus || item.status || ""),
|
||||
}))
|
||||
.filter((item) => item.id);
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const openPurchase = (item) => {
|
||||
if (creating.value || !item?.id) return;
|
||||
purchaseError.value = "";
|
||||
selectedPackage.value = item;
|
||||
};
|
||||
const closePurchase = () => {
|
||||
if (creating.value) return;
|
||||
selectedPackage.value = null;
|
||||
purchaseError.value = "";
|
||||
};
|
||||
const createOrder = async () => {
|
||||
const item = selectedPackage.value;
|
||||
if (!item?.id || creating.value) return;
|
||||
creating.value = true;
|
||||
purchaseError.value = "";
|
||||
try {
|
||||
await appApi.createVipOrder(
|
||||
{ packageId: item.id, payType: "wechat" },
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
selectedPackage.value = null;
|
||||
orderNotice.value = `“${item.name}”订单已创建,并已从服务端重新读取订单状态。`;
|
||||
await loadVip();
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
purchaseError.value = error?.message || "订单创建失败,请稍后重试。";
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({});
|
||||
const setVipStatusBar = (style, color) => {
|
||||
const navigator = globalThis.plus?.navigator;
|
||||
if (!navigator) return;
|
||||
const apply = () => {
|
||||
navigator.setStatusBarStyle(style);
|
||||
navigator.setStatusBarBackground(color);
|
||||
};
|
||||
apply();
|
||||
if (statusBarTimer) clearTimeout(statusBarTimer);
|
||||
statusBarTimer = setTimeout(apply, 80);
|
||||
};
|
||||
onLoad(() => {
|
||||
setVipStatusBar("dark", "#f4ebde");
|
||||
loadVip();
|
||||
});
|
||||
onReady(() => setVipStatusBar("dark", "#f4ebde"));
|
||||
onShow(() => {
|
||||
setVipStatusBar("dark", "#f4ebde");
|
||||
if (state.value !== "loading") loadVip();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
if (statusBarTimer) clearTimeout(statusBarTimer);
|
||||
const navigator = globalThis.plus?.navigator;
|
||||
if (navigator) {
|
||||
navigator.setStatusBarStyle("light");
|
||||
navigator.setStatusBarBackground("#b52e22");
|
||||
}
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.orders-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f7f2e9;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.order-empty {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
min-height: 284rpx;
|
||||
overflow: hidden;
|
||||
margin: -20rpx -30rpx 0;
|
||||
padding: 36rpx 48rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.vip-intro__background {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.78;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-intro__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vip-intro text {
|
||||
display: block;
|
||||
}
|
||||
.vip-intro__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.vip-intro__title {
|
||||
margin-top: 16rpx;
|
||||
color: #33261d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.vip-intro__copy {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 190rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 36rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.package-list,
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.package-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 38rpx 36rpx 32rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.52);
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 253, 248, 0.95);
|
||||
box-shadow: 0 16rpx 32rpx rgba(102, 48, 33, 0.12);
|
||||
}
|
||||
.package-courtyard {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
.package-card > :not(.package-courtyard) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.package-heading > view {
|
||||
min-width: 0;
|
||||
}
|
||||
.package-heading > view text {
|
||||
display: block;
|
||||
}
|
||||
.package-heading > view text:first-child {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.package-heading > view text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: #77675a;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.package-price {
|
||||
color: $brand-red;
|
||||
font-family: Georgia, STKaiti, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
}
|
||||
.package-duration {
|
||||
color: #837164;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.purchase-button {
|
||||
display: inline-flex;
|
||||
min-width: 184rpx;
|
||||
min-height: 70rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
border: 0;
|
||||
border-radius: 999rpx;
|
||||
background: $brand-red;
|
||||
color: #fffaf1;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
line-height: normal;
|
||||
}
|
||||
.purchase-button::after {
|
||||
border: 0;
|
||||
}
|
||||
.purchase-button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 44rpx 6rpx 16rpx;
|
||||
color: #38281d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-heading text:last-child {
|
||||
color: #8a796b;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 400;
|
||||
}
|
||||
.order-notice {
|
||||
display: block;
|
||||
margin: 0 6rpx 14rpx;
|
||||
color: #786556;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.order-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 4rpx;
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.14);
|
||||
color: #48372a;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.order-card > view text {
|
||||
display: block;
|
||||
}
|
||||
.order-card > view text:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
.order-card > view text:last-child {
|
||||
margin-top: 8rpx;
|
||||
color: #8a796b;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.order-card text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: #7f6959;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.order-empty {
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
:deep(.orders-header .page-header) {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.08);
|
||||
background: #f4ebde;
|
||||
}
|
||||
:deep(.orders-header.page-header-slot) {
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
}
|
||||
:deep(.orders-header .header-title) {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 37rpx, 26px);
|
||||
}
|
||||
:deep(.orders-header .header-back__icon) {
|
||||
filter: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
:deep(.payment-sheet.app-dialog-layer) {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
background: rgba(37, 26, 18, 0.38);
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 76vh;
|
||||
border: 0;
|
||||
border-radius: 38rpx 38rpx 0 0;
|
||||
background: #fffdf9;
|
||||
box-shadow: 0 -12rpx 38rpx rgba(50, 33, 20, 0.16);
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__content) {
|
||||
align-items: stretch;
|
||||
padding: 34rpx 42rpx calc(30rpx + env(safe-area-inset-bottom));
|
||||
text-align: left;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__content::before) {
|
||||
display: block;
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
align-self: center;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(76, 58, 45, 0.22);
|
||||
content: "";
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__copy) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__title) {
|
||||
color: #302319;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
letter-spacing: 1rpx;
|
||||
text-align: left;
|
||||
}
|
||||
:deep(.payment-sheet .app-dialog__actions) {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 4rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
:deep(.payment-sheet .app-button) {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
:deep(.payment-sheet .app-button__skin) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--primary) {
|
||||
border-radius: 999rpx;
|
||||
background: $brand-red;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--secondary) {
|
||||
min-height: 60rpx;
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.payment-sheet .app-button--secondary .app-button__label) {
|
||||
color: #8a796b;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 400;
|
||||
}
|
||||
.sheet-order-summary {
|
||||
margin-top: 24rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(117, 84, 52, 0.12);
|
||||
border-radius: 20rpx;
|
||||
background: #fffaf4;
|
||||
}
|
||||
.sheet-order-summary > view,
|
||||
.payment-method {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 0 24rpx;
|
||||
border-bottom: 1rpx solid rgba(117, 84, 52, 0.1);
|
||||
color: #5a4432;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.sheet-order-summary > view:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.sheet-order-summary > view text:last-child,
|
||||
.payment-method text:last-child {
|
||||
color: #302319;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.sheet-order-summary > view:last-child text:last-child {
|
||||
color: $brand-red;
|
||||
font-family: Georgia, STKaiti, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
}
|
||||
.payment-method {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
margin-top: 16rpx;
|
||||
border: 1rpx solid rgba(117, 84, 52, 0.12);
|
||||
border-radius: 20rpx;
|
||||
background: #fffaf4;
|
||||
}
|
||||
.payment-method text:last-child {
|
||||
color: $brand-red;
|
||||
}
|
||||
.payment-copy,
|
||||
.purchase-error {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 16rpx;
|
||||
color: #8a796b;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.purchase-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
.package-card {
|
||||
padding-right: 28rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
.purchase-button {
|
||||
min-width: 166rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,234 +0,0 @@
|
||||
<!-- 页面编号:M-10;用途:协议、隐私、版本与退出登录。 -->
|
||||
<template>
|
||||
<view
|
||||
class="about-page"
|
||||
:class="{ 'about-state--logging-out': logoutSubmitting }"
|
||||
>
|
||||
<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="item in agreementItems"
|
||||
:key="item.key"
|
||||
class="settings-row"
|
||||
role="button"
|
||||
:aria-label="item.label"
|
||||
@click="openAgreement(item)"
|
||||
><view
|
||||
><text>{{ item.label }}</text
|
||||
><text>{{ item.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>
|
||||
<AppDialog
|
||||
:visible="agreementVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="协议与说明"
|
||||
:title="activeAgreement.label"
|
||||
:message="activeAgreement.copy"
|
||||
confirm-text="关闭"
|
||||
@confirm="agreementVisible = false"
|
||||
@close="agreementVisible = false"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import manifest from "@/manifest.json";
|
||||
import { appApi, createRequestController } from "@/utils/api.js";
|
||||
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
import { session } from "@/utils/session.js";
|
||||
|
||||
const appVersion = manifest.versionName;
|
||||
const agreementItems = [
|
||||
{
|
||||
key: "terms",
|
||||
label: "用户协议",
|
||||
note: "了解账号与服务使用规则",
|
||||
copy: "用户协议正文将在正式协议服务接入后展示。当前页面不代表最终法律文本。",
|
||||
},
|
||||
{
|
||||
key: "privacy",
|
||||
label: "隐私政策",
|
||||
note: "了解个人资料如何使用与保护",
|
||||
copy: "隐私政策正文将在正式协议服务接入后展示。敏感资料默认按权限与脱敏规则展示。",
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "版本说明",
|
||||
note: `当前版本 ${appVersion}`,
|
||||
copy: `当前安装版本为 ${appVersion}。基础版本采用浅色国风主题。`,
|
||||
},
|
||||
];
|
||||
const activeAgreement = reactive({ label: "", copy: "" });
|
||||
const agreementVisible = ref(false);
|
||||
const logoutVisible = ref(false);
|
||||
const logoutSubmitting = ref(false);
|
||||
const logoutRequestController = createRequestController();
|
||||
const openAgreement = (item) => {
|
||||
activeAgreement.label = item.label;
|
||||
activeAgreement.copy = item.copy;
|
||||
agreementVisible.value = true;
|
||||
};
|
||||
const confirmLogout = async () => {
|
||||
if (logoutSubmitting.value) return false;
|
||||
logoutSubmitting.value = true;
|
||||
try {
|
||||
await appApi.logout({ requestController: logoutRequestController });
|
||||
} catch {
|
||||
// 退出请求的结果未知时仍必须撤销本机会话,不能保留旧授权或自动重试。
|
||||
} finally {
|
||||
session.clear();
|
||||
logoutVisible.value = false;
|
||||
logoutSubmitting.value = false;
|
||||
}
|
||||
return goRoot("A01");
|
||||
};
|
||||
const closeActiveDialog = () => {
|
||||
if (logoutVisible.value) logoutVisible.value = false;
|
||||
else agreementVisible.value = false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: agreementVisible.value || logoutVisible.value,
|
||||
"close-transient": closeActiveDialog,
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => logoutRequestController.abort());
|
||||
</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;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<view class="promotion-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"><PageHeader title="推广中心" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="promotion-hero">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>家谱服务推荐</text>
|
||||
<text>这里会展示家谱相关服务和活动。</text>
|
||||
</view>
|
||||
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
|
||||
<AppLoading text="正在读取推广内容" />
|
||||
</view>
|
||||
<view v-else-if="promotionListState === 'error'" class="promotion-state-card">
|
||||
<text>暂时无法读取推广内容</text>
|
||||
<text>{{ promotionListError || '请检查网络后重新加载。' }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadPromotions" />
|
||||
</view>
|
||||
<view v-else-if="!promotions.length" class="promotion-state-card">
|
||||
<text>当前没有推广内容</text>
|
||||
<text>后续正式发布的家谱服务推荐会在这里展示。</text>
|
||||
</view>
|
||||
<view v-else class="promotion-list">
|
||||
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
|
||||
<view
|
||||
v-for="item in promotions"
|
||||
:key="item.id"
|
||||
class="promotion-card"
|
||||
:class="{ 'promotion-card--linked': item.targetUrl }"
|
||||
:role="item.targetUrl ? 'button' : undefined"
|
||||
:aria-label="item.targetUrl ? `${item.title},查看详情` : undefined"
|
||||
@click="openPromotion(item)"
|
||||
>
|
||||
<image
|
||||
v-if="item.coverFile?.accessUrl"
|
||||
class="promotion-card__cover"
|
||||
:src="item.coverFile.accessUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text class="promotion-card__title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="promotion-card__description">{{ item.description }}</text>
|
||||
<view v-if="item.targetUrl" class="promotion-card__actions">
|
||||
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
|
||||
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const promotions = ref([]);
|
||||
const promotionListState = ref("loading");
|
||||
const promotionListError = ref("");
|
||||
const operationFeedback = ref("");
|
||||
const promotionListRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const loadPromotions = async () => {
|
||||
promotionListRequestController.abort();
|
||||
promotionListState.value = "loading";
|
||||
promotionListError.value = "";
|
||||
try {
|
||||
const promotionRows = await siteContentApi.getPromotions({
|
||||
requestController: promotionListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
promotions.value = promotionRows;
|
||||
promotionListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
promotionListError.value = getRequestErrorMessage(error, "推广内容暂时无法加载,请稍后再试。");
|
||||
promotionListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openPromotion = async (item) => {
|
||||
const targetUrl = item?.targetUrl || "";
|
||||
if (!targetUrl) return;
|
||||
const showExternalLinkError = () => {
|
||||
operationFeedback.value = "链接暂时打不开,请稍后再试。";
|
||||
};
|
||||
try {
|
||||
await openSiteContentTarget(targetUrl, showExternalLinkError);
|
||||
} catch {
|
||||
operationFeedback.value = targetUrl.startsWith("/")
|
||||
? "这个页面暂时打不开,请稍后再试。"
|
||||
: "链接暂时打不开,请稍后再试。";
|
||||
}
|
||||
};
|
||||
const copyPromotionLink = (item) => {
|
||||
if (!item?.targetUrl || typeof uni?.setClipboardData !== "function") {
|
||||
operationFeedback.value = "当前设备暂时不能复制链接。";
|
||||
return;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: item.targetUrl,
|
||||
success: () => { operationFeedback.value = "推广链接已复制,可以发给家人。"; },
|
||||
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
|
||||
});
|
||||
};
|
||||
const sharePromotion = (item) => {
|
||||
if (!item?.targetUrl) return;
|
||||
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus?.share?.sendWithSystem === "function") {
|
||||
plus.share.sendWithSystem(
|
||||
{ type: "text", content },
|
||||
() => { operationFeedback.value = "已打开系统分享。"; },
|
||||
() => { operationFeedback.value = "已取消分享。"; },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
if (typeof navigator?.share === "function") {
|
||||
navigator.share({ title: item.title, text: item.description, url: item.targetUrl })
|
||||
.then(() => { operationFeedback.value = "分享已完成。"; })
|
||||
.catch(() => { operationFeedback.value = "已取消分享。"; });
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
copyPromotionLink(item);
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onLoad(loadPromotions);
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
promotionListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.promotion-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 28rpx 30rpx 72rpx;
|
||||
}
|
||||
.promotion-hero,
|
||||
.promotion-state-card,
|
||||
.promotion-list {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.promotion-hero {
|
||||
display: flex;
|
||||
min-height: 250rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 38rpx 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.promotion-hero image {
|
||||
width: 90rpx;
|
||||
height: 102rpx;
|
||||
}
|
||||
.promotion-hero text,
|
||||
.promotion-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.promotion-hero text:nth-child(2),
|
||||
.promotion-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.promotion-hero text:last-child,
|
||||
.promotion-state-card text:last-child {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.promotion-state-card {
|
||||
display: flex;
|
||||
min-height: 210rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 38rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.promotion-state-card .app-button { margin-top: 28rpx; }
|
||||
.promotion-list { display: grid; gap: 16rpx; }
|
||||
.promotion-card {
|
||||
overflow: hidden;
|
||||
padding: 26rpx 28rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.26);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.promotion-card--linked { cursor: pointer; }
|
||||
.promotion-card__cover {
|
||||
display: block;
|
||||
width: calc(100% + 56rpx);
|
||||
height: 260rpx;
|
||||
margin: -26rpx -28rpx 22rpx;
|
||||
}
|
||||
.promotion-card text { display: block; }
|
||||
.promotion-card__title { color: $ink; font-size: clamp(16px, 28rpx, 20px); font-weight: 700; }
|
||||
.promotion-card__description { margin-top: 10rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.6; }
|
||||
.promotion-feedback { display: block; padding: 16rpx 20rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 10rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.promotion-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 16rpx; gap: 12rpx; }
|
||||
.promotion-card__actions .app-button { width: auto; min-width: 142rpx; }
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 22rpx;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:M-03;用途:展示当前账号可读取的安全概览,不执行安全写入。 -->
|
||||
<template>
|
||||
<view class="security-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
@@ -9,12 +8,12 @@
|
||||
<view v-if="loading" class="state-card"
|
||||
><AppLoading text="正在读取账号安全资料"
|
||||
/></view>
|
||||
<view v-else-if="loadError" class="state-card">
|
||||
<text>账号安全资料暂时无法读取</text><text>{{ loadError }}</text>
|
||||
<view v-else-if="securityProfileError" class="state-card">
|
||||
<text>账号安全资料暂时无法读取</text><text>{{ securityProfileError }}</text>
|
||||
<AppButton block label="重新读取" @click="loadProfile" />
|
||||
</view>
|
||||
<view v-else class="security-card">
|
||||
<text class="security-card__title">当前账号安全概览</text>
|
||||
<text class="security-card__title">账号安全概览</text>
|
||||
<view class="security-field"
|
||||
><text>绑定手机号</text><text>{{ maskedPhone }}</text></view
|
||||
>
|
||||
@@ -23,7 +22,7 @@
|
||||
><text>{{ profile.userNo || "未提供" }}</text></view
|
||||
>
|
||||
<text class="security-note"
|
||||
>当前仅展示可确认的账户信息,其他安全记录暂不可查看。</text
|
||||
>这里显示可查看的账号信息,其他安全信息暂不支持查看。</text
|
||||
>
|
||||
<AppButton block label="修改密码" @click="openPassword" />
|
||||
<AppButton
|
||||
@@ -45,16 +44,17 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { profileApi } from "@/services/api/profile-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const profile = reactive({ phone: "", userNo: "" });
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const controller = createRequestController();
|
||||
const securityProfileError = ref("");
|
||||
const securityProfileRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const maskedPhone = computed(() =>
|
||||
/^\d{7,}$/.test(profile.phone)
|
||||
@@ -65,13 +65,15 @@ const maskedPhone = computed(() =>
|
||||
const loadProfile = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
securityProfileError.value = "";
|
||||
try {
|
||||
const data = await appApi.getProfile({ requestController: controller });
|
||||
if (pageActive) Object.assign(profile, data);
|
||||
const profileResponse = await profileApi.getProfile({
|
||||
requestController: securityProfileRequestController,
|
||||
});
|
||||
if (pageActive) Object.assign(profile, profileResponse);
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
loadError.value = error?.message || "请稍后重试";
|
||||
securityProfileError.value = getRequestErrorMessage(error, "请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) loading.value = false;
|
||||
}
|
||||
@@ -82,7 +84,7 @@ const openPhone = () => openPage("M05", {}, "M03");
|
||||
onShow(loadProfile);
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
securityProfileRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,7 +108,7 @@ onUnload(() => {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 54rpx 42rpx 44rpx;
|
||||
@include adaptive-family-content;
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.state-card {
|
||||
text-align: center;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<ModulePageBackground module="profile" />
|
||||
<view class="page-layer"
|
||||
><PageHeader
|
||||
class="orders-header"
|
||||
title="VIP 与订单"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content page-layer">
|
||||
<view class="vip-intro">
|
||||
<image
|
||||
class="vip-intro__background"
|
||||
src="/static/assets/modules/profile/opaque/vip-hero-landscape.png"
|
||||
mode="aspectFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="vip-intro__content">
|
||||
<text class="vip-intro__eyebrow">家谱传承服务</text>
|
||||
<text class="vip-intro__title">会员服务</text>
|
||||
<text class="vip-intro__copy">{{ purchaseSummary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="readState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取 VIP 服务信息" />
|
||||
</view>
|
||||
<view v-else-if="readState === 'error'" class="state-card">
|
||||
<text>暂时无法读取 VIP 服务信息</text>
|
||||
<text>{{ vipReadError || "请检查网络后重新加载。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadVipData" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="vip-section">
|
||||
<view class="vip-section__heading">
|
||||
<text>可选套餐</text>
|
||||
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
|
||||
</view>
|
||||
<view v-if="packages.length" class="vip-package-list">
|
||||
<view v-for="item in packages" :key="item.key" class="vip-package">
|
||||
<view class="vip-package__copy">
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="item.description">{{ item.description }}</text>
|
||||
</view>
|
||||
<view class="vip-package__price">
|
||||
<text>¥{{ item.price }}</text>
|
||||
<text v-if="item.originalPrice">原价 ¥{{ item.originalPrice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
|
||||
</view>
|
||||
|
||||
<view class="vip-section">
|
||||
<view class="vip-section__heading">
|
||||
<text>订单记录</text>
|
||||
<text>仅供查看</text>
|
||||
</view>
|
||||
<view v-if="orders.length" class="vip-order-list">
|
||||
<view v-for="item in orders" :key="item.key" class="vip-order">
|
||||
<view>
|
||||
<text>{{ item.packageName }}</text>
|
||||
<text v-if="item.paidAt || item.expiresAt">{{ item.paidAt || item.expiresAt }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<text>状态:{{ item.statusLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="vip-empty-state"><text>当前没有订单记录</text></view>
|
||||
</view>
|
||||
</template>
|
||||
<AppButton block label="查看购买说明" @click="openServiceNotice" />
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="serviceNoticeVisible"
|
||||
:title="capability.enabled ? '购买功能说明' : '当前不能在线购买'"
|
||||
confirm-text="我知道了"
|
||||
:close-on-mask="false"
|
||||
@confirm="closeServiceNotice"
|
||||
>
|
||||
<text class="notice-copy">{{ serviceNoticeCopy }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { vipApi } from "@/services/api/vip-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const packages = ref([]);
|
||||
const orders = ref([]);
|
||||
const capability = reactive({ enabled: false, disabledReason: "" });
|
||||
const readState = ref("loading");
|
||||
const vipReadError = ref("");
|
||||
const serviceNoticeVisible = ref(false);
|
||||
const packageController = createRequestController();
|
||||
const orderController = createRequestController();
|
||||
const capabilityController = createRequestController();
|
||||
let active = true;
|
||||
const purchaseSummary = computed(() =>
|
||||
capability.enabled
|
||||
? "当前可以购买会员,支付功能正在完成最后确认。"
|
||||
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
|
||||
);
|
||||
const serviceNoticeCopy = computed(() =>
|
||||
capability.enabled
|
||||
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
|
||||
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
|
||||
);
|
||||
|
||||
const loadVipData = async () => {
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
readState.value = "loading";
|
||||
vipReadError.value = "";
|
||||
try {
|
||||
const [capabilityResult, packageRows, orderRows] = await Promise.all([
|
||||
vipApi.getVipCapability({ requestController: capabilityController }),
|
||||
vipApi.getVipPackages({ requestController: packageController }),
|
||||
vipApi.getVipOrders({ requestController: orderController }),
|
||||
]);
|
||||
if (!active) return;
|
||||
capability.enabled = capabilityResult.enabled;
|
||||
capability.disabledReason = capabilityResult.disabledReason;
|
||||
packages.value = packageRows;
|
||||
orders.value = orderRows;
|
||||
readState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
vipReadError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
readState.value = "error";
|
||||
}
|
||||
};
|
||||
const openServiceNotice = () => {
|
||||
serviceNoticeVisible.value = true;
|
||||
};
|
||||
const closeServiceNotice = () => {
|
||||
serviceNoticeVisible.value = false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: serviceNoticeVisible.value,
|
||||
"close-transient": closeServiceNotice,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadVipData);
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
capabilityController.abort();
|
||||
packageController.abort();
|
||||
orderController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.orders-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: #f7f2e9;
|
||||
}
|
||||
.page-layer {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx 72rpx;
|
||||
}
|
||||
.vip-intro {
|
||||
position: relative;
|
||||
min-height: 284rpx;
|
||||
overflow: hidden;
|
||||
margin: -20rpx -30rpx 0;
|
||||
padding: 36rpx 48rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.vip-intro__background {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.78;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-intro__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vip-intro text,
|
||||
.state-card text,
|
||||
.vip-package text,
|
||||
.vip-order text,
|
||||
.vip-empty-state text,
|
||||
.notice-copy {
|
||||
display: block;
|
||||
}
|
||||
.vip-intro__eyebrow {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 5rpx;
|
||||
}
|
||||
.vip-intro__title {
|
||||
margin-top: 16rpx;
|
||||
color: #33261d;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(20px, 38rpx, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.vip-intro__copy {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card,
|
||||
.vip-section {
|
||||
@include adaptive-profile-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 184rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
padding: 34rpx 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:last-child,
|
||||
.notice-copy {
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.vip-section {
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.vip-section__heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.vip-section__heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vip-section__heading text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
}
|
||||
.vip-package-list,
|
||||
.vip-order-list {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.vip-package,
|
||||
.vip-order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.68);
|
||||
}
|
||||
.vip-package__copy,
|
||||
.vip-order > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.vip-package__copy text:first-child,
|
||||
.vip-order > view:first-child text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 25rpx, 19px);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.vip-package__copy text:last-child,
|
||||
.vip-order > view:first-child text:last-child,
|
||||
.vip-order > view:last-child text:last-child {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(12px, 20rpx, 15px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.vip-package__price,
|
||||
.vip-order > view:last-child {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
.vip-package__price text:first-child,
|
||||
.vip-order > view:last-child text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vip-package__price text:last-child {
|
||||
margin-top: 7rpx;
|
||||
color: #a49382;
|
||||
font-size: clamp(11px, 18rpx, 14px);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.vip-empty-state {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 20rpx;
|
||||
border: 1rpx dashed rgba(128, 89, 49, 0.24);
|
||||
border-radius: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: center;
|
||||
}
|
||||
.page-content > .app-button {
|
||||
width: 100%;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.notice-copy {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user