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
+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>