Files
YGChatCS/src/pages-aigc/recharge/recharge.vue
duanshuwen 14defcc5b7 feat(recharge): add full virtual recharge flow for aigc credits
- Replace legacy recharge API endpoints with new dedicated VirtualRechargeApi with sensitive request logging
- Implement pending order tracking, automatic payment reconciliation, and recovery for interrupted payments
- Overhaul recharge component logic to support both pre-defined package and custom amount recharge
- Fix currency price formatting to consistently display 2 decimal places for all monetary values
- Remove unused xiaoqi-avatar asset and related component imports
- Simplify Vite build asset naming configuration
- Switch application to test development mode
2026-07-22 16:17:16 +08:00

464 lines
13 KiB
Vue

<template>
<view class="aigc-recharge-page">
<TopNavBar
title="积分充值"
title-align="left"
background="transparent"
title-color="#172033"
back-icon-color="#172033"
:align-with-menu-button="true"
:z-index="3"
@back="handleBack"
/>
<view class="aigc-recharge-content">
<BalanceCard
:points="balancePoints"
@ledger="handleOpenLedger"
/>
<RechargePackageList
:packages="rechargeOptions"
:selected-key="selectedRechargeKey"
@select="handleSelectRechargeOption"
/>
<CustomAmountCard
v-model="customAmount"
:selected="rechargeMode === 'custom'"
@select="handleSelectCustomAmount"
/>
<RechargeFooter
:price="payPrice"
:loading="paying"
:agreed="agreementAccepted"
:agreement-ready="agreementReady"
@update:agreed="agreementAccepted = $event"
@view-agreement="handleViewAgreement"
@pay="handlePay"
/>
</view>
<AgreePopup
:visible="agreementVisible"
title="积分充值协议"
:agreement="agreementContent"
@close="agreementVisible = false"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import AgreePopup from "@/pages/login/components/AgreePopup/index.vue";
import { getPointsTopUpAgreement } from "@/request/api/AigcApi.js";
import {
confirmVirtualRechargeOrder,
createVirtualRechargeOrder,
getVirtualRechargeOptions,
getVirtualRechargeOrder,
} from "@/request/api/VirtualRechargeApi.js";
import BalanceCard from "./components/BalanceCard/index.vue";
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
import RechargeFooter from "./components/RechargeFooter/index.vue";
import RechargePackageList from "./components/RechargePackageList/index.vue";
import {
getPendingVirtualRechargeOrderNo,
removePendingVirtualRechargeOrderNo,
setPendingVirtualRechargeOrderNo,
} from "./services/pendingVirtualRecharge.js";
import {
getWechatClientPlatform,
getWechatLoginCode,
requestWechatVirtualPayment,
} from "./services/wechatVirtualPayment.js";
import {
buildVirtualRechargeOrderPayload,
classifyVirtualRechargeStatus,
normalizeVirtualPaymentOrder,
VIRTUAL_RECHARGE_STATUS_KIND,
} from "./utils/virtualRecharge.js";
const ORDER_QUERY_INTERVAL = 2000;
const MAX_ORDER_QUERY_ATTEMPTS = 5;
const { pointBalance: balancePoints, fetchCurrentCredit } = useCurrentCredit();
const rechargeOptions = ref([]);
const selectedRechargeOption = ref(null);
const customAmount = ref("");
const rechargeMode = ref("package");
const paying = ref(false);
const reconciling = ref(false);
const agreementAccepted = ref(false);
const agreementContent = ref("");
const agreementLoading = ref(false);
const agreementVisible = ref(false);
let orderQueryTimer = null;
let orderQueryResolver = null;
let pageVisible = false;
const agreementReady = computed(() => Boolean(agreementContent.value.trim()));
const selectedRechargeKey = computed(() => {
if (rechargeMode.value !== "package") return "";
return String(selectedRechargeOption.value?.optionCode || "");
});
const payPrice = computed(() => {
if (rechargeMode.value === "custom") {
const amount = Number(customAmount.value);
return Number.isInteger(amount) ? amount : 0;
}
const amountFen = Number(selectedRechargeOption.value?.expectedAmountFen);
return Number.isInteger(amountFen) && amountFen > 0 ? amountFen / 100 : 0;
});
const showToast = (title, icon = "none") => {
uni.showToast({ title, icon });
};
const clearOrderQueryTimer = () => {
if (orderQueryTimer) {
clearTimeout(orderQueryTimer);
orderQueryTimer = null;
}
if (orderQueryResolver) {
orderQueryResolver(false);
orderQueryResolver = null;
}
};
const waitForNextOrderQuery = () => {
if (!pageVisible) return Promise.resolve(false);
return new Promise((resolve) => {
orderQueryResolver = resolve;
orderQueryTimer = setTimeout(() => {
orderQueryTimer = null;
orderQueryResolver = null;
resolve(pageVisible);
}, ORDER_QUERY_INTERVAL);
});
};
const fetchRechargeOptions = async () => {
try {
const res = await getVirtualRechargeOptions();
if (res?.code === 0 && Array.isArray(res.data)) {
rechargeOptions.value = res.data;
if (rechargeMode.value === "package") {
selectedRechargeOption.value = res.data[0] || null;
}
return;
}
rechargeOptions.value = [];
selectedRechargeOption.value = null;
} catch (error) {
console.error("获取虚拟支付充值档位失败", error);
rechargeOptions.value = [];
selectedRechargeOption.value = null;
}
};
const fetchPointsTopUpAgreement = async (showError = false) => {
if (agreementLoading.value) return false;
agreementLoading.value = true;
try {
const res = await getPointsTopUpAgreement();
const content = typeof res?.data === "string" ? res.data.trim() : "";
if (res?.code === 0 && content) {
agreementContent.value = content;
return true;
}
agreementContent.value = "";
agreementAccepted.value = false;
if (showError) showToast(res?.msg || "协议加载失败,请重试");
} catch (error) {
console.error("获取积分充值协议失败", error);
agreementContent.value = "";
agreementAccepted.value = false;
if (showError) showToast("协议加载失败,请重试");
} finally {
agreementLoading.value = false;
}
return false;
};
const finishOrderByStatus = async (orderData) => {
const statusKind = classifyVirtualRechargeStatus(orderData?.status);
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.PAID) {
removePendingVirtualRechargeOrderNo();
await fetchCurrentCredit();
showToast("充值成功", "success");
return true;
}
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.REFUNDED) {
removePendingVirtualRechargeOrderNo();
await fetchCurrentCredit();
showToast("充值订单已退款");
return true;
}
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.FAILED) {
removePendingVirtualRechargeOrderNo();
showToast("充值订单异常,请重新发起支付");
return true;
}
return false;
};
const getPaymentErrorMessage = (error) => {
switch (Number(error?.errCode)) {
case -2:
return "已取消支付";
case -4:
return "支付被微信风控拦截";
case -15007:
return "微信登录状态已过期,请重新发起支付";
default:
return "支付失败,请重试";
}
};
const reconcileVirtualRechargeOrder = async (
rechargeOrderNo,
{ clientPaymentError = null, showPendingToast = true } = {}
) => {
if (reconciling.value || !rechargeOrderNo) return "skipped";
reconciling.value = true;
let latestOrder = null;
try {
let confirmArgs = {};
try {
const wxLoginCode = await getWechatLoginCode();
confirmArgs = { wxLoginCode };
} catch (error) {
console.warn("刷新微信登录凭证失败,将使用现有服务端会话确认订单", error);
}
try {
const confirmRes = await confirmVirtualRechargeOrder(
rechargeOrderNo,
confirmArgs
);
if (confirmRes?.code === 0 && confirmRes.data) {
latestOrder = confirmRes.data;
if (await finishOrderByStatus(latestOrder)) return "terminal";
}
} catch (error) {
console.error("服务端确认虚拟支付订单失败", error);
}
if (Number(clientPaymentError?.errCode) === -2 && latestOrder?.status === "UNPAID") {
removePendingVirtualRechargeOrderNo();
showToast("已取消支付");
return "cancelled";
}
for (let attempt = 0; attempt < MAX_ORDER_QUERY_ATTEMPTS; attempt += 1) {
const shouldContinue = await waitForNextOrderQuery();
if (!shouldContinue) return "paused";
try {
const queryRes = await getVirtualRechargeOrder(rechargeOrderNo);
if (queryRes?.code === 0 && queryRes.data) {
latestOrder = queryRes.data;
if (await finishOrderByStatus(latestOrder)) return "terminal";
}
} catch (error) {
console.error("查询虚拟支付充值订单失败", error);
}
}
if (clientPaymentError && latestOrder?.status === "UNPAID") {
removePendingVirtualRechargeOrderNo();
showToast(getPaymentErrorMessage(clientPaymentError));
return "client-failed";
}
if (showPendingToast) {
showToast("支付结果确认中,请稍后返回查看");
}
return "pending";
} finally {
reconciling.value = false;
}
};
const recoverPendingVirtualRechargeOrder = async () => {
const rechargeOrderNo = getPendingVirtualRechargeOrderNo();
if (!rechargeOrderNo || paying.value || reconciling.value) return;
paying.value = true;
uni.showLoading({ title: "正在确认支付结果...", mask: true });
try {
await reconcileVirtualRechargeOrder(rechargeOrderNo);
} finally {
uni.hideLoading();
paying.value = false;
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) uni.navigateBack();
};
const handleOpenLedger = () => {
uni.navigateTo({
url: "/pages-aigc/pointsDetails/pointsDetails",
fail: () => showToast("积分明细"),
});
};
const handleSelectRechargeOption = (option) => {
rechargeMode.value = "package";
selectedRechargeOption.value = option;
customAmount.value = "";
};
const handleSelectCustomAmount = () => {
rechargeMode.value = "custom";
selectedRechargeOption.value = null;
};
const handleViewAgreement = async () => {
if (agreementReady.value) {
agreementVisible.value = true;
return;
}
if (agreementLoading.value) {
showToast("协议加载中,请稍候");
return;
}
const loaded = await fetchPointsTopUpAgreement(true);
if (loaded) agreementVisible.value = true;
};
const validateRechargeSelection = () => {
if (rechargeMode.value === "package") {
if (!String(selectedRechargeOption.value?.optionCode || "").trim()) {
throw new Error("请选择充值档位");
}
return;
}
const amountText = String(customAmount.value || "").trim();
const amount = Number(amountText);
if (!/^\d{1,6}$/.test(amountText) || amount < 1 || amount > 999999) {
throw new Error("请输入1至999999的整数充值金额");
}
};
const handlePay = async () => {
if (paying.value) return;
try {
validateRechargeSelection();
} catch (error) {
showToast(error.message);
return;
}
if (agreementLoading.value) {
showToast("协议加载中,请稍候");
return;
}
if (!agreementReady.value) {
showToast("协议加载失败,请重试");
return;
}
if (!agreementAccepted.value) {
showToast("请先阅读并同意积分充值协议");
return;
}
paying.value = true;
clearOrderQueryTimer();
uni.showLoading({ title: "正在发起支付...", mask: true });
try {
const clientPlatform = getWechatClientPlatform();
const wxLoginCode = await getWechatLoginCode();
const createOrderPayload = buildVirtualRechargeOrderPayload({
mode: rechargeMode.value,
rechargeOptionCode: selectedRechargeOption.value?.optionCode,
rechargeAmount: customAmount.value,
wxLoginCode,
clientPlatform,
});
const createRes = await createVirtualRechargeOrder(createOrderPayload);
if (createRes?.code !== 0 || !createRes.data) {
throw new Error(createRes?.msg || "充值下单失败,请重试");
}
const paymentOrder = normalizeVirtualPaymentOrder(createRes.data);
setPendingVirtualRechargeOrderNo(paymentOrder.rechargeOrderNo);
uni.hideLoading();
let clientPaymentError = null;
try {
await requestWechatVirtualPayment(paymentOrder);
} catch (error) {
clientPaymentError = error;
}
pageVisible = true;
uni.showLoading({ title: "正在确认支付结果...", mask: true });
await reconcileVirtualRechargeOrder(paymentOrder.rechargeOrderNo, {
clientPaymentError,
});
} catch (error) {
console.error("虚拟支付积分充值失败", error);
showToast(error?.message || "充值下单失败,请重试");
} finally {
uni.hideLoading();
paying.value = false;
}
};
onLoad(() => {
agreementAccepted.value = false;
fetchRechargeOptions();
fetchPointsTopUpAgreement();
});
onShow(() => {
pageVisible = true;
fetchCurrentCredit();
recoverPendingVirtualRechargeOrder();
});
onHide(() => {
pageVisible = false;
clearOrderQueryTimer();
});
onUnload(() => {
pageVisible = false;
clearOrderQueryTimer();
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>