Files
jiapuapp/pages/profile/vip.vue
T

588 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="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="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">
<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>
<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>
</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.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>
<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 { 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: "", 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 || "当前可查看套餐和已有订单,暂时不能在线购买。",
);
const loadVipData = async () => {
capabilityController.abort();
packageController.abort();
orderController.abort();
readState.value = "loading";
vipReadError.value = "";
try {
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";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
vipReadError.value = getRequestErrorMessage(error, "请稍后重试。");
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,
"close-transient": closeServiceNotice,
});
onBackPress((event) => handleBackPress(event, requestBack));
onLoad(loadVipData);
onUnload(() => {
active = false;
capabilityController.abort();
packageController.abort();
orderController.abort();
paymentController.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 calc(72rpx + env(safe-area-inset-bottom));
}
.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 {
min-width: 0;
overflow-wrap: anywhere;
}
.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;
}
.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;
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 > .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;
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;
}
.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>