feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+23 -1
View File
@@ -204,7 +204,7 @@ onUnload(() => {
}
.page-content {
padding: 36rpx 32rpx 68rpx;
padding: 36rpx 32rpx calc(68rpx + env(safe-area-inset-bottom));
}
.state-card,
@@ -258,6 +258,10 @@ onUnload(() => {
gap: 20rpx;
}
.invitation-card__heading {
align-items: flex-start;
}
.invitation-card__heading text:first-child {
flex: 1;
color: #40291e;
@@ -295,7 +299,25 @@ onUnload(() => {
}
.invitation-card__actions {
flex-wrap: wrap;
justify-content: flex-end;
padding-top: 8rpx;
}
.invitation-card__actions .app-button {
min-width: 152rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.state-card,
.invitation-card {
padding-right: 24rpx;
padding-left: 24rpx;
}
}
</style>
+23 -8
View File
@@ -28,18 +28,22 @@
:password="!passwordVisible[field.key]"
maxlength="32"
:aria-label="field.label"
:aria-invalid="Boolean(errors[field.key])"
:aria-describedby="errors[field.key] ? `${field.key}-password-error` : undefined"
:placeholder="field.placeholder"
@input="errors[field.key] = ''"
/>
<view
<button
class="password-toggle"
role="button"
tabindex="0"
:aria-label="`${passwordVisible[field.key] ? '隐藏' : '显示'}${field.label}`"
:aria-pressed="passwordVisible[field.key]"
@click="togglePassword(field.key)"
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</view
>{{ passwordVisible[field.key] ? "隐藏" : "显示" }}</button
>
</view>
<text v-if="errors[field.key]" class="field-error">{{
<text v-if="errors[field.key]" :id="`${field.key}-password-error`" class="field-error" role="alert">{{
errors[field.key]
}}</text>
</view>
@@ -84,7 +88,8 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { calcMD5 } from "@/utils/md5.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { session } from "@/utils/session.js";
import {
PASSWORD_POLICY_MESSAGE,
validatePassword,
@@ -161,11 +166,13 @@ const savePassword = async () => {
{ requestController: passwordChangeRequestController },
);
if (!pageActive) return;
session.clear();
await goRoot("A01");
if (!pageActive) return;
passwordForm.current = "";
passwordForm.next = "";
passwordForm.confirm = "";
baseline.value = formSnapshot.value;
showToast("密码修改成功");
} catch (error) {
if (!pageActive) return;
if (passwordChangeGuard.recordFailure(passwordChangeAttempt, error)) {
@@ -215,7 +222,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.security-tip,
.form-panel {
@@ -264,18 +271,26 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.password-toggle {
display: flex;
min-height: 68rpx;
min-height: 80rpx;
align-items: center;
justify-content: flex-end;
width: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.password-toggle::after {
border: 0;
}
.field-error {
display: block;
padding-top: 7rpx;
+43 -12
View File
@@ -16,6 +16,22 @@
><text>完成安全验证和短信校验后即可更新你的登录手机号</text></view
>
<view class="form-panel">
<view class="field-block">
<view class="form-row">
<text>当前密码</text>
<input
v-model="currentPassword"
password
maxlength="32"
aria-label="当前密码"
:aria-invalid="Boolean(errors.currentPassword)"
:aria-describedby="errors.currentPassword ? 'change-phone-password-error' : undefined"
placeholder="请输入当前登录密码"
@input="errors.currentPassword = ''"
/>
</view>
<text v-if="errors.currentPassword" id="change-phone-password-error" class="field-error" role="alert">{{ errors.currentPassword }}</text>
</view>
<view class="field-block">
<view class="form-row">
<text>新手机号</text>
@@ -24,11 +40,13 @@
type="number"
maxlength="11"
aria-label="新手机号"
:aria-invalid="Boolean(errors.phone)"
:aria-describedby="errors.phone ? 'change-phone-error' : undefined"
placeholder="请输入新手机号"
@input="handlePhoneInput"
/>
</view>
<text v-if="errors.phone" class="field-error">{{ errors.phone }}</text>
<text v-if="errors.phone" id="change-phone-error" class="field-error" role="alert">{{ errors.phone }}</text>
</view>
<view class="field-block">
<view class="form-row form-row--code">
@@ -38,6 +56,8 @@
type="number"
maxlength="4"
aria-label="短信验证码"
:aria-invalid="Boolean(errors.smsCode)"
:aria-describedby="errors.smsCode ? 'change-phone-code-error' : undefined"
placeholder="4 位验证码"
@input="errors.smsCode = ''"
/>
@@ -48,7 +68,7 @@
@click="prepareGetCode"
>{{ cooldownSeconds > 0 ? `${cooldownSeconds}s 后重试` : '获取验证码' }}</button>
</view>
<text v-if="errors.smsCode" class="field-error">{{ errors.smsCode }}</text>
<text v-if="errors.smsCode" id="change-phone-code-error" class="field-error" role="alert">{{ errors.smsCode }}</text>
</view>
</view>
<AppButton
@@ -104,16 +124,19 @@ import {
} from "@/utils/auth/verification.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { goRoot, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
import { calcMD5 } from "@/utils/md5.js";
import { session } from "@/utils/session.js";
const currentPassword = ref("");
const phone = ref("");
const smsCode = ref("");
const errors = reactive({ phone: "", smsCode: "" });
const errors = reactive({ currentPassword: "", phone: "", smsCode: "" });
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 formSnapshot = computed(() => JSON.stringify({ currentPassword: currentPassword.value, phone: phone.value, smsCode: smsCode.value }));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const phoneChangeController = createRequestController();
@@ -185,19 +208,24 @@ const prepareGetCode = async () => {
};
const validateForm = () => {
errors.currentPassword = currentPassword.value ? "" : "请输入当前密码";
errors.phone = isAuthPhone(phone.value) ? "" : "请输入正确手机号";
errors.smsCode =
sentPhone.value !== phone.value
? "请先获取当前手机号的验证码"
? "请先获取手机号的验证码"
: /^\d{4}$/.test(smsCode.value)
? ""
: "请输入 4 位验证码";
return !errors.phone && !errors.smsCode;
return !errors.currentPassword && !errors.phone && !errors.smsCode;
};
const submitPhoneChange = async () => {
if (phoneState.value !== "ready" || !validateForm()) return;
const phoneChangePayload = { phone: phone.value, smsCode: smsCode.value };
const phoneChangePayload = {
phone: phone.value,
smsCode: smsCode.value,
currentPasswordHash: calcMD5(currentPassword.value),
};
const phoneChangeAttempt = phoneChangeGuard.begin(phoneChangePayload);
if (phoneChangeAttempt === null) {
showToast("上次换绑结果暂时无法确认,请重新登录确认手机号,不要重复提交");
@@ -210,11 +238,14 @@ const submitPhoneChange = async () => {
{ requestController: phoneChangeController },
);
if (!isPageActive) return;
session.clear();
await goRoot("A01");
if (!isPageActive) return;
currentPassword.value = "";
phone.value = "";
smsCode.value = "";
sentPhone.value = "";
baseline.value = formSnapshot.value;
showToast("手机号换绑成功");
} catch (error) {
if (!isPageActive) return;
if (phoneChangeGuard.recordFailure(phoneChangeAttempt, error)) {
@@ -262,7 +293,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.security-tip,
.form-panel {
@@ -314,7 +345,7 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
@@ -324,7 +355,7 @@ onUnload(() => {
justify-content: center;
justify-self: end;
width: 198rpx;
min-height: 68rpx;
min-height: 80rpx;
margin: 0;
padding: 0 14rpx;
box-sizing: border-box;
+13 -1
View File
@@ -131,7 +131,7 @@ onUnload(() => {
}
.document-content {
flex: 1;
padding: 24rpx 28rpx 72rpx;
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.document-sheet,
.document-state-card {
@@ -219,4 +219,16 @@ onUnload(() => {
.document-state-card .app-button {
margin-top: 28rpx;
}
@media (max-width: 340px) {
.document-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.document-sheet {
padding-right: 28rpx;
padding-left: 28rpx;
}
}
</style>
+33 -2
View File
@@ -84,7 +84,11 @@
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
</view>
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
<text v-if="withdrawal.withdrawalNo">提现单号{{ withdrawal.withdrawalNo }}</text>
<text>收款人{{ withdrawal.payoutAccountName || '未填写' }}</text>
<text v-if="withdrawal.auditRemark">审核备注{{ withdrawal.auditRemark }}</text>
<text v-if="withdrawal.payoutReference">打款参考号{{ withdrawal.payoutReference }}</text>
<text v-if="withdrawal.paidAt">到账时间{{ recordTimeLabel(withdrawal.paidAt) }}</text>
<text v-if="withdrawal.failureReason">原因{{ withdrawal.failureReason }}</text>
<AppButton
v-if="withdrawal.withdrawalStatus === 'PENDING'"
@@ -121,7 +125,7 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app";
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";
@@ -137,6 +141,7 @@ import {
} from "@/services/api/request-controller.js";
import { earningApi } from "@/services/api/earning-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { handleBackPress } from "@/utils/navigation/gateway.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import {
formatSignedMoney,
@@ -257,10 +262,18 @@ const cancelWithdrawal = async () => {
if (isPageActive) cancelling.value = false;
}
};
const closeCancellationDialog = () => {
if (cancelling.value) return false;
cancelTarget.value = null;
return true;
};
onShow(() => {
void loadAll();
});
onBackPress((event) =>
cancelTarget.value ? handleBackPress(event, closeCancellationDialog) : false,
);
onUnload(() => {
isPageActive = false;
summaryController.abort();
@@ -275,7 +288,7 @@ onUnload(() => {
.earnings-page {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 48rpx;
padding-bottom: env(safe-area-inset-bottom);
background: $paper;
}
@@ -334,6 +347,11 @@ onUnload(() => {
font-size: clamp(14px, 23rpx, 17px);
}
.summary-row text {
min-width: 0;
overflow-wrap: anywhere;
}
.summary-note {
margin: 18rpx 0;
line-height: 1.6;
@@ -442,4 +460,17 @@ onUnload(() => {
margin-top: 24rpx;
}
@media (max-width: 340px) {
.earnings-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.summary-panel,
.record-card {
padding-right: 20rpx;
padding-left: 20rpx;
}
}
</style>
+58 -8
View File
@@ -109,14 +109,27 @@
</template>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃资料修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
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";
@@ -129,11 +142,16 @@ import {
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { finishPage, returnTo } from "@/utils/navigation/gateway.js";
import {
finishPage,
handleBackPress,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const form = reactive({
nickName: "",
@@ -162,11 +180,18 @@ const saveError = ref("");
const committedProfilePayload = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const profileReadController = createRequestController();
const avatarUploadController = createRequestController();
const profileSaveController = createRequestController();
let feedbackTimer = null;
let isPageActive = true;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sexOptions = Object.freeze([
Object.freeze({ value: "", label: "请选择" }),
...PROFILE_SEX_OPTIONS,
@@ -181,6 +206,16 @@ const avatarMessage = computed(() => {
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
});
const profileSnapshot = computed(() => JSON.stringify({
nickName: form.nickName,
realName: form.realName,
sex: form.sex,
birthday: form.birthday,
email: form.email,
avatar: avatarId.value || original.avatar || null,
}));
const originalSnapshot = computed(() => JSON.stringify(original));
const isDirty = computed(() => profileSnapshot.value !== originalSnapshot.value);
const showFeedback = (message) => {
feedbackMessage.value = message;
@@ -291,6 +326,15 @@ const saveProfile = async () => {
});
if (!isPageActive) return;
committedProfilePayload.value = payloadFingerprint;
for (const field of ["nickName", "realName", "sex", "birthday", "email"]) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
original[field] = payload[field];
}
}
if (Object.prototype.hasOwnProperty.call(payload, "avatar")) {
original.avatar = payload.avatar;
avatarId.value = null;
}
}
await finishPage(
"M01",
@@ -307,18 +351,24 @@ const saveProfile = async () => {
}
};
const backToProfile = () => {
if (saving.value || uploading.value) return;
return returnTo("M01");
};
const backToProfile = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: saving.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onLoad(loadProfile);
onBackPress((event) => handleBackPress(event, backToProfile));
onUnload(() => {
isPageActive = false;
profileReadController.abort();
avatarUploadController.abort();
profileSaveController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
discardConfirmation.dispose();
});
</script>
@@ -336,7 +386,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 30rpx 72rpx;
padding: 24rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.form-panel {
@@ -435,7 +485,7 @@ onUnload(() => {
.picker-value {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
+26 -14
View File
@@ -122,7 +122,7 @@
class="history-card"
>
<view class="history-card__meta">
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
<text>{{ feedbackTypeLabel(feedback) }} · {{ feedbackReference(feedback) }}</text>
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
</view>
<text
@@ -171,9 +171,7 @@ 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 {
FEEDBACK_TYPE_OPTIONS as feedbackTypes
} from "@/services/api/feedback-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -190,9 +188,11 @@ const feedbackStatusLabels = {
2: "已处理",
3: "暂不处理",
};
const feedbackTypeLabel = (feedbackTypeValue) =>
feedbackTypes.find(
(feedbackType) => feedbackType.value === feedbackTypeValue,
const feedbackTypes = ref([]);
const feedbackTypeLabel = (feedback) =>
String(feedback?.feedbackTypeLabel || "").trim() ||
feedbackTypes.value.find(
(feedbackType) => feedbackType.value === feedback?.feedbackType,
)?.label || "其他";
const feedbackStatusLabel = (handleStatus) =>
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
@@ -234,6 +234,7 @@ const toggleFeedbackExpanded = (feedback) => {
let pageActive = true;
const feedbackHistoryRequestController = createRequestController();
const feedbackSubmissionRequestController = createRequestController();
const feedbackTypeRequestController = createRequestController();
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
const syncSubmissionView = () => {
const submissionView = submissionSession.view(feedbackForm);
@@ -261,6 +262,13 @@ const loadFeedbackHistory = async () => {
);
}
};
const loadFeedbackTypes = async () => {
feedbackTypeRequestController.abort();
feedbackTypes.value = await businessDictionaryApi.getBusinessDictionaryOptions(
"gen_feedback_type",
{ requestController: feedbackTypeRequestController },
);
};
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
@@ -325,11 +333,15 @@ const requestBack = () =>
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onLoad(loadFeedbackHistory);
onLoad(() => {
void loadFeedbackTypes().catch(() => { feedbackTypes.value = []; });
void loadFeedbackHistory();
});
onUnload(() => {
pageActive = false;
feedbackHistoryRequestController.abort();
feedbackSubmissionRequestController.abort();
feedbackTypeRequestController.abort();
discardConfirmation.dispose();
});
</script>
@@ -347,7 +359,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 26rpx 30rpx 72rpx;
padding: 26rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.lead {
display: block;
@@ -477,7 +489,7 @@ onUnload(() => {
}
.history-retry {
min-width: 180rpx;
min-height: 72rpx;
min-height: 80rpx;
margin: 0;
padding: 0 28rpx;
border: 1px solid rgba(164, 41, 36, 0.55);
@@ -485,7 +497,7 @@ onUnload(() => {
background: rgba(255, 255, 255, 0.82);
color: $brand-red;
font-size: clamp(14px, 22rpx, 16px);
line-height: 72rpx;
line-height: 80rpx;
}
.history-list {
display: grid;
@@ -523,14 +535,14 @@ onUnload(() => {
-webkit-line-clamp: 4;
}
.history-expand {
min-height: 72rpx;
min-height: 80rpx;
margin: 4rpx 0 0 auto;
padding: 0 8rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 15px);
line-height: 72rpx;
line-height: 80rpx;
}
.history-expand::after {
border: 0;
@@ -559,7 +571,7 @@ onUnload(() => {
.contact-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
text-align: right;
+17 -10
View File
@@ -170,7 +170,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 28rpx 72rpx;
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.help-hero,
.help-state-card,
@@ -225,7 +225,7 @@ onUnload(() => {
}
.help-search {
width: 100%;
min-height: 72rpx;
min-height: 80rpx;
box-sizing: border-box;
padding: 0 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
@@ -245,13 +245,13 @@ onUnload(() => {
.help-category {
display: inline-flex;
min-width: 112rpx;
min-height: 58rpx;
min-height: 80rpx;
align-items: center;
justify-content: center;
margin-right: 12rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.24);
border-radius: 29rpx;
border-radius: 40rpx;
background: rgba(255, 252, 245, 0.54);
color: $ink-muted;
font-size: clamp(13px, 22rpx, 16px);
@@ -263,20 +263,21 @@ onUnload(() => {
border: 0;
}
.help-category--active {
border-color: #8d2722;
background: #8d2722;
border-color: $brand-red;
background: $brand-red;
color: #fff7e8;
}
.help-article-list {
display: grid;
gap: 14rpx;
width: 100%;
margin-top: 18rpx;
}
.help-article {
overflow: hidden;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.66);
background: rgba(255, 252, 245, 0.88);
}
.help-article__question {
display: flex;
@@ -311,12 +312,12 @@ onUnload(() => {
}
.help-article__indicator {
flex: 0 0 auto;
color: #9f170f;
color: $brand-red-dark;
font-size: clamp(12px, 20rpx, 15px);
}
.help-article__answer {
display: block;
padding: 0 22rpx 24rpx;
padding: 16rpx 22rpx 24rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.16);
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
@@ -336,7 +337,7 @@ onUnload(() => {
margin-top: 16rpx;
border: 0;
background: transparent;
color: #9f170f;
color: $brand-red-dark;
font-size: clamp(14px, 23rpx, 17px);
}
.help-feedback-card {
@@ -344,6 +345,12 @@ onUnload(() => {
padding: 32rpx 34rpx;
text-align: center;
}
.help-feedback-card text {
display: block;
}
.help-feedback-card text:first-child {
font-size: clamp(17px, 28rpx, 20px);
}
@media (max-width: 340px) {
.page-content {
padding-right: 20rpx;
+34 -1
View File
@@ -594,7 +594,7 @@ onUnload(() => {
top: 337rpx;
right: 61rpx;
width: 154rpx;
min-height: 52rpx;
min-height: 72rpx;
margin: 0;
padding: 0;
border: 0;
@@ -814,4 +814,37 @@ onUnload(() => {
opacity: 0.68;
}
@media (max-width: 340px) {
.profile-hero__content {
padding-right: 42rpx;
padding-left: 42rpx;
}
.profile-hero__emblem {
width: 176rpx;
height: 176rpx;
}
.profile-metadata {
grid-template-columns: minmax(0, 1fr);
}
.profile-metadata__item:first-child {
border-right: 0;
}
.profile-metadata__item + .profile-metadata__item {
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: auto;
}
.profile-services {
margin-right: 38rpx;
margin-left: 38rpx;
}
}
</style>
+121 -6
View File
@@ -11,6 +11,31 @@
<text>家谱服务推荐</text>
<text>这里会展示家谱相关服务和活动</text>
</view>
<text v-if="operationFeedback" class="promotion-feedback" role="status">{{ operationFeedback }}</text>
<view class="referral-card">
<view class="referral-card__heading">
<text>邀请家人注册</text>
<text>推荐关系由注册接口一次性确认前端不会在注册后补绑</text>
</view>
<AppLoading v-if="referralState === 'loading'" text="正在读取我的推荐码" />
<view v-else-if="referralState === 'error'" class="referral-card__state">
<text>{{ referralError || "推荐码暂时无法读取。" }}</text>
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
</view>
<view v-else-if="referralProfile.enabled" class="referral-card__content">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<view class="referral-card__actions">
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
<AppButton compact label="分享给家人" @click="shareReferral" />
</view>
</view>
<view v-else class="referral-card__state">
<text>{{ referralProfile.disabledReason || "推荐功能暂未开放。" }}</text>
</view>
</view>
<view v-if="promotionListState === 'loading'" class="promotion-state-card">
<AppLoading text="正在读取推广内容" />
</view>
@@ -24,7 +49,6 @@
<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"
@@ -42,7 +66,7 @@
/>
<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">
<view v-if="item.targetUrl" class="promotion-card__actions" @click.stop>
<AppButton compact type="secondary" label="复制链接" @click.stop="copyPromotionLink(item)" />
<AppButton compact label="分享给家人" @click.stop="sharePromotion(item)" />
</view>
@@ -63,6 +87,7 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { referralApi } from "@/services/api/referral-service.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";
@@ -71,9 +96,39 @@ const promotions = ref([]);
const promotionListState = ref("loading");
const promotionListError = ref("");
const operationFeedback = ref("");
const referralState = ref("loading");
const referralError = ref("");
const referralProfile = ref({
enabled: false,
disabledReason: "",
referralCode: "",
shareTitle: "",
shareDescription: "",
shareUrl: "",
referredUserCount: 0,
});
const promotionListRequestController = createRequestController();
const referralRequestController = createRequestController();
let pageActive = true;
const loadReferralProfile = async () => {
referralRequestController.abort();
referralState.value = "loading";
referralError.value = "";
try {
const profile = await referralApi.getMyReferralProfile({
requestController: referralRequestController,
});
if (!pageActive) return;
referralProfile.value = profile;
referralState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
referralError.value = getRequestErrorMessage(error, "推荐码暂时无法读取,请稍后重试。");
referralState.value = "error";
}
};
const loadPromotions = async () => {
promotionListRequestController.abort();
promotionListState.value = "loading";
@@ -117,6 +172,39 @@ const copyPromotionLink = (item) => {
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
});
};
const copyText = (content, successMessage) => {
if (!content || typeof uni?.setClipboardData !== "function") {
operationFeedback.value = "当前设备暂时不能复制。";
return;
}
uni.setClipboardData({
data: content,
success: () => { operationFeedback.value = successMessage; },
fail: () => { operationFeedback.value = "复制失败,请稍后再试。"; },
});
};
const copyReferralCode = () =>
copyText(referralProfile.value.referralCode, "推荐码已复制,可以发给家人。");
const copyReferralLink = () =>
copyText(referralProfile.value.shareUrl, "推荐链接已复制,可以发给家人。");
const shareReferral = () => {
const profile = referralProfile.value;
if (!profile.enabled || !profile.shareUrl) return;
const content = [profile.shareTitle, profile.shareDescription, profile.shareUrl]
.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
copyReferralLink();
};
const sharePromotion = (item) => {
if (!item?.targetUrl) return;
const content = [item.title, item.description, item.targetUrl].filter(Boolean).join("\n");
@@ -142,11 +230,15 @@ const sharePromotion = (item) => {
};
const requestBack = () => goBack();
onLoad(loadPromotions);
onLoad(() => {
void loadReferralProfile();
void loadPromotions();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
promotionListRequestController.abort();
referralRequestController.abort();
});
</script>
@@ -163,9 +255,10 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.promotion-hero,
.referral-card,
.promotion-state-card,
.promotion-list {
@include adaptive-profile-content;
@@ -211,8 +304,25 @@ onUnload(() => {
padding: 38rpx;
text-align: center;
}
.referral-card {
margin-top: 20rpx;
padding: 28rpx;
border: 1rpx solid rgba(128, 89, 49, 0.26);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.82);
}
.referral-card text { display: block; }
.referral-card__heading text:first-child { color: $ink; font-size: clamp(17px, 29rpx, 21px); font-weight: 700; }
.referral-card__heading text:last-child,
.referral-card__content > text:last-of-type,
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.referral-card__content,
.referral-card__state { margin-top: 22rpx; }
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
.promotion-state-card .app-button { margin-top: 28rpx; }
.promotion-list { display: grid; gap: 16rpx; }
.promotion-list { display: grid; gap: 18rpx; margin-top: 20rpx; }
.promotion-card {
overflow: hidden;
padding: 26rpx 28rpx;
@@ -230,7 +340,7 @@ onUnload(() => {
.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-feedback { display: block; margin-top: 16rpx; 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); line-height: 1.5; overflow-wrap: anywhere; }
.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) {
@@ -238,5 +348,10 @@ onUnload(() => {
padding-right: 22rpx;
padding-left: 22rpx;
}
.promotion-card__actions .app-button {
min-width: 0;
flex: 1 1 220rpx;
}
}
</style>
+98 -11
View File
@@ -21,9 +21,6 @@
><text>账号编号</text
><text>{{ profile.userNo || "未提供" }}</text></view
>
<text class="security-note"
>这里显示可查看的账号信息其他安全信息暂不支持查看</text
>
<AppButton block label="修改密码" @click="openPassword" />
<AppButton
type="secondary"
@@ -31,6 +28,14 @@
label="换绑手机号"
@click="openPhone"
/>
<AppButton
type="secondary"
block
:disabled="wechatProviderState !== 'ready' || bindingWechat"
:label="bindingWechat ? '正在绑定微信…' : '绑定微信'"
@click="bindWechat"
/>
<text v-if="wechatBindingMessage" class="security-message">{{ wechatBindingMessage }}</text>
</view>
</view>
</view>
@@ -48,6 +53,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { authApi } from "@/services/api/auth-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
@@ -55,6 +61,10 @@ const profile = reactive({ phone: "", userNo: "" });
const loading = ref(false);
const securityProfileError = ref("");
const securityProfileRequestController = createRequestController();
const wechatBindingRequestController = createRequestController();
const wechatProviderState = ref("unknown");
const bindingWechat = ref(false);
const wechatBindingMessage = ref("");
let pageActive = true;
const maskedPhone = computed(() =>
/^\d{7,}$/.test(profile.phone)
@@ -81,10 +91,72 @@ const loadProfile = async () => {
const backToProfile = () => returnTo("M01");
const openPassword = () => openPage("M04", {}, "M03");
const openPhone = () => openPage("M05", {}, "M03");
onShow(loadProfile);
const detectWechatProvider = () => {
// #ifdef APP-PLUS
if (typeof uni?.getProvider !== "function") {
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "当前设备不支持微信授权";
return;
}
uni.getProvider({
service: "oauth",
success: ({ provider = [] } = {}) => {
if (!pageActive) return;
wechatProviderState.value = provider.includes("weixin") ? "ready" : "unavailable";
if (wechatProviderState.value === "unavailable") {
wechatBindingMessage.value = "当前设备未安装或未配置微信授权";
}
},
fail: () => {
if (!pageActive) return;
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "暂时无法使用微信授权";
},
});
// #endif
// #ifndef APP-PLUS
wechatProviderState.value = "unavailable";
wechatBindingMessage.value = "请在 App 中绑定微信";
// #endif
};
const requestWechatAuthorizationCode = () =>
new Promise((resolve, reject) => {
uni.login({
provider: "weixin",
onlyAuthorize: true,
success: ({ code } = {}) => resolve(code),
fail: reject,
});
});
const bindWechat = async () => {
if (bindingWechat.value || wechatProviderState.value !== "ready") return;
bindingWechat.value = true;
wechatBindingMessage.value = "";
try {
const code = await requestWechatAuthorizationCode();
await authApi.bindWechat(
{ code },
{ requestController: wechatBindingRequestController },
);
if (pageActive) wechatBindingMessage.value = "微信绑定成功";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
const errorText = String(error?.errMsg || error?.message || "");
wechatBindingMessage.value = /cancel|取消/i.test(errorText)
? "已取消微信绑定"
: getRequestErrorMessage(error, "微信绑定失败,请稍后重试");
} finally {
if (pageActive) bindingWechat.value = false;
}
};
onShow(() => {
void loadProfile();
detectWechatProvider();
});
onUnload(() => {
pageActive = false;
securityProfileRequestController.abort();
wechatBindingRequestController.abort();
});
</script>
@@ -101,7 +173,8 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.security-card {
@@ -155,13 +228,27 @@ onUnload(() => {
text-align: right;
overflow-wrap: anywhere;
}
.security-note {
margin-top: 22rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.6;
}
.security-card .app-button {
margin-top: 20rpx;
}
.security-message {
margin-top: 16rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
text-align: center;
}
@media (max-width: 340px) {
.page-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.state-card,
.security-card {
padding-right: 30rpx;
padding-left: 30rpx;
}
}
</style>
+4 -4
View File
@@ -399,7 +399,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 28rpx 30rpx 72rpx;
padding: 28rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.brand-card {
@include adaptive-profile-summary;
@@ -513,7 +513,7 @@ onUnload(() => {
}
.deactivate-link {
width: auto;
min-height: 54rpx;
min-height: 80rpx;
margin: 16rpx 0 0;
padding: 0;
border: 0;
@@ -550,14 +550,14 @@ onUnload(() => {
.form-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.code-action {
justify-self: end;
width: 198rpx;
min-height: 68rpx;
min-height: 80rpx;
margin: 0;
padding: 0 14rpx;
box-sizing: border-box;
+235 -6
View File
@@ -36,6 +36,27 @@
<text>可选套餐</text>
<text>{{ capability.enabled ? "购买资格已开放" : "当前仅供查看" }}</text>
</view>
<view v-if="capability.enabled" class="payment-methods">
<text class="payment-methods__title">支付方式</text>
<view class="payment-methods__options" role="radiogroup" aria-label="支付方式">
<button
v-for="method in paymentMethodOptions"
:key="method.method"
class="payment-method"
:class="{ 'payment-method--selected': selectedPaymentMethod === method.method }"
:disabled="!method.available || paymentState === 'submitting'"
role="radio"
:aria-checked="selectedPaymentMethod === method.method"
@click="selectPaymentMethod(method)"
>
<text>{{ method.label }}</text>
<text v-if="!method.available">{{ method.unavailableReason }}</text>
</button>
</view>
<text v-if="!selectedPaymentMethod" class="payment-methods__error"
>当前安装包没有可用支付通道请完成打包配置后再购买</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">
@@ -46,6 +67,13 @@
<text>{{ item.price }}</text>
<text v-if="item.originalPrice">原价 {{ item.originalPrice }}</text>
</view>
<AppButton
v-if="capability.enabled"
compact
label="立即购买"
:disabled="paymentState === 'submitting' || !selectedPaymentMethod"
@click="purchasePackage(item)"
/>
</view>
</view>
<view v-else class="vip-empty-state"><text>当前没有可展示的套餐</text></view>
@@ -60,7 +88,9 @@
<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>
<text v-if="item.orderNo">订单号{{ item.orderNo }}</text>
<text v-if="item.paidAt">支付时间{{ item.paidAt }}</text>
<text v-if="item.expiresAt">到期时间{{ item.expiresAt }}</text>
</view>
<view>
<text>{{ item.amount }}</text>
@@ -98,27 +128,67 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { vipApi } from "@/services/api/vip-service.js";
import { VIP_PAYMENT_METHOD } from "@/services/api/vip-contract.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const packages = ref([]);
const orders = ref([]);
const capability = reactive({ enabled: false, disabledReason: "" });
const capability = reactive({ enabled: false, disabledReason: "", paymentMethods: [] });
const nativePaymentProviders = ref([]);
const selectedPaymentMethod = ref("");
const readState = ref("loading");
const vipReadError = ref("");
const serviceNoticeVisible = ref(false);
const paymentState = ref("idle");
const packageController = createRequestController();
const orderController = createRequestController();
const capabilityController = createRequestController();
const paymentController = createRequestController();
const orderCreationGuard = createNonIdempotentWriteGuard();
let active = true;
const createVipRequestId = () => {
if (typeof globalThis.crypto?.randomUUID === "function") {
return `app-vip-${globalThis.crypto.randomUUID()}`;
}
return `app-vip-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const paymentProviderByMethod = Object.freeze({
[VIP_PAYMENT_METHOD.WECHAT]: "wxpay",
[VIP_PAYMENT_METHOD.ALIPAY]: "alipay",
});
const paymentMethodOptions = computed(() =>
capability.paymentMethods.map((method) => {
const requiredProvider = paymentProviderByMethod[method.method];
const clientAvailable =
!requiredProvider || nativePaymentProviders.value.includes(requiredProvider);
return {
...method,
available: method.enabled && clientAvailable,
unavailableReason: method.enabled
? "当前安装包未配置此通道"
: method.disabledReason || "服务端暂未开放",
};
}),
);
const selectedPaymentMethodLabel = computed(
() =>
paymentMethodOptions.value.find(
(method) => method.method === selectedPaymentMethod.value,
)?.label || "",
);
const purchaseSummary = computed(() =>
capability.enabled
? "当前可以购买会员,支付功能正在完成最后确认。"
? selectedPaymentMethodLabel.value
? `当前使用${selectedPaymentMethodLabel.value},支付结果以服务端订单状态为准。`
: "购买资格已开放,但当前安装包没有可用支付通道。"
: capability.disabledReason || "当前可查看套餐和订单,暂时不能在线购买。",
);
const serviceNoticeCopy = computed(() =>
capability.enabled
? "你的账号目前可以购买会员。为避免重复扣款,支付按钮将在确认完成后开放;现在可以先查看套餐和订单。"
? "支付完成后会向服务端核对订单状态;若渠道已扣款但状态仍在处理中,请勿重复下单。余额支付由服务端在同一事务内完成扣款和开通。"
: capability.disabledReason || "当前可查看套餐和已有订单,暂时不能在线购买。",
);
@@ -129,14 +199,22 @@ const loadVipData = async () => {
readState.value = "loading";
vipReadError.value = "";
try {
const [capabilityResult, packageRows, orderRows] = await Promise.all([
const [capabilityResult, packageRows, orderRows, providerIds] = await Promise.all([
vipApi.getVipCapability({ requestController: capabilityController }),
vipApi.getVipPackages({ requestController: packageController }),
vipApi.getVipOrders({ requestController: orderController }),
getNativePaymentProviders(),
]);
if (!active) return;
capability.enabled = capabilityResult.enabled;
capability.disabledReason = capabilityResult.disabledReason;
capability.paymentMethods = capabilityResult.paymentMethods;
nativePaymentProviders.value = providerIds;
const currentSelection = paymentMethodOptions.value.find(
(method) => method.method === selectedPaymentMethod.value && method.available,
);
selectedPaymentMethod.value = currentSelection?.method ||
paymentMethodOptions.value.find((method) => method.available)?.method || "";
packages.value = packageRows;
orders.value = orderRows;
readState.value = "ready";
@@ -146,12 +224,107 @@ const loadVipData = async () => {
readState.value = "error";
}
};
const getNativePaymentProviders = () =>
new Promise((resolve) => {
if (typeof uni?.getProvider !== "function") {
resolve([]);
return;
}
uni.getProvider({
service: "payment",
success: ({ provider = [] } = {}) => resolve(provider),
fail: () => resolve([]),
});
});
const selectPaymentMethod = (method) => {
if (!method.available || paymentState.value === "submitting") return;
selectedPaymentMethod.value = method.method;
};
const openServiceNotice = () => {
serviceNoticeVisible.value = true;
};
const closeServiceNotice = () => {
serviceNoticeVisible.value = false;
};
const requestNativePayment = (paymentOrder) =>
new Promise((resolve, reject) => {
if (!paymentOrder.nativePaymentRequired) {
resolve();
return;
}
uni.requestPayment({
provider: paymentProviderByMethod[paymentOrder.paymentMethod],
orderInfo: paymentOrder.orderInfo,
success: resolve,
fail: reject,
});
});
const isPaymentCancellation = (error) =>
/cancel/i.test(String(error?.errMsg || error?.message || ""));
const purchasePackage = async (vipPackage) => {
if (!capability.enabled || !selectedPaymentMethod.value || paymentState.value === "submitting") return;
const genealogyId = genealogyContext.getCurrentGenealogyId();
const orderPayload = {
packageId: String(vipPackage.id),
genealogyId,
paymentMethod: selectedPaymentMethod.value,
requestId: createVipRequestId(),
};
const orderAttempt = orderCreationGuard.begin(orderPayload);
if (orderAttempt === null) {
uni.showToast({
title: "上次下单结果待确认,请先查看订单,不要重复购买",
icon: "none",
});
return;
}
paymentState.value = "submitting";
let transactionId = "";
try {
const paymentOrder = await vipApi.createVipOrder(
vipPackage.id,
genealogyId,
selectedPaymentMethod.value,
orderPayload.requestId,
{ requestController: paymentController },
);
transactionId = paymentOrder.transactionId;
await requestNativePayment(paymentOrder);
const paymentStatus = await vipApi.getVipPaymentStatus(
paymentOrder.transactionId,
{ requestController: paymentController },
);
uni.showToast({
title: paymentStatus.status === "SUCCESS" ? "购买成功" : "支付结果确认中,请勿重复下单",
icon: paymentStatus.status === "SUCCESS" ? "success" : "none",
});
await loadVipData();
} catch (error) {
if (!active || isRequestCancelled(error)) return;
let feedback = getRequestErrorMessage(error, "支付未完成,请稍后重试。");
if (!transactionId && orderCreationGuard.recordFailure(orderAttempt, error)) {
feedback = "下单结果暂时无法确认,请先查看订单,不要重复购买";
}
if (transactionId && isPaymentCancellation(error)) {
try {
await vipApi.closeVipPayment(transactionId, {
requestController: paymentController,
});
feedback = "已取消支付";
await loadVipData();
} catch (closeError) {
if (isRequestCancelled(closeError)) return;
feedback = "支付已取消,订单关闭状态待确认";
}
}
uni.showToast({
title: feedback,
icon: "none",
});
} finally {
if (active) paymentState.value = "idle";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: serviceNoticeVisible.value,
@@ -165,6 +338,7 @@ onUnload(() => {
capabilityController.abort();
packageController.abort();
orderController.abort();
paymentController.abort();
});
</script>
@@ -181,7 +355,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 20rpx 30rpx 72rpx;
padding: 20rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.vip-intro {
position: relative;
@@ -269,6 +443,11 @@ onUnload(() => {
justify-content: space-between;
gap: 16rpx;
}
.vip-section__heading text {
min-width: 0;
overflow-wrap: anywhere;
}
.vip-section__heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
@@ -285,6 +464,36 @@ onUnload(() => {
gap: 14rpx;
margin-top: 18rpx;
}
.payment-methods {
margin-bottom: 24rpx;
padding: 22rpx;
border: 1rpx solid rgba(128, 89, 49, 0.2);
border-radius: 10rpx;
background: rgba(255, 252, 245, 0.7);
}
.payment-methods__title { display: block; color: $ink; font-size: clamp(14px, 24rpx, 17px); font-weight: 700; }
.payment-methods__options { display: flex; flex-wrap: wrap; margin-top: 14rpx; gap: 12rpx; }
.payment-method {
display: flex;
min-height: var(--app-touch-min);
flex: 1 1 180rpx;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 12rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.28);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.58);
color: $ink;
text-align: left;
}
.payment-method::after { border: 0; }
.payment-method--selected { border-color: #9e251b; background: rgba(158, 37, 27, 0.08); color: #9e251b; }
.payment-method[disabled] { opacity: 0.56; }
.payment-method text:first-child { font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
.payment-method text:last-child:not(:first-child),
.payment-methods__error { margin-top: 6rpx; color: $ink-muted; font-size: clamp(12px, 19rpx, 14px); line-height: 1.4; }
.payment-methods__error { display: block; margin-top: 14rpx; color: #9e251b; }
.vip-package,
.vip-order {
display: flex;
@@ -321,6 +530,11 @@ onUnload(() => {
flex: 0 0 auto;
text-align: right;
}
.vip-package > .app-button {
width: auto;
min-width: 132rpx;
flex: 0 0 auto;
}
.vip-package__price text:first-child,
.vip-order > view:last-child text:first-child {
color: $brand-red;
@@ -354,5 +568,20 @@ onUnload(() => {
padding-right: 20rpx;
padding-left: 20rpx;
}
.vip-package {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14rpx 18rpx;
}
.vip-package > .app-button {
width: 100%;
grid-column: 1 / -1;
}
.vip-order {
align-items: flex-start;
}
}
</style>