588 lines
19 KiB
Vue
588 lines
19 KiB
Vue
<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>
|