index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { defineComponent, ref, reactive, computed, onUnmounted } from 'vue';
  2. import { NButton, NSpin } from 'naive-ui';
  3. import styles from './index.module.less';
  4. import TheQrCode from '@/components/TheQrCode';
  5. import qs from 'query-string';
  6. import {
  7. getPaymentConfig,
  8. createVipOrder,
  9. getOrderDetail
  10. } from '@/api/payment';
  11. import { useUserStore } from '@/store/modules/users';
  12. import { getHttpOrigin } from '/src/helpers/utils';
  13. // API返回的套餐类型
  14. interface VipPackageApiItem {
  15. id: number;
  16. title: string;
  17. number: number;
  18. unit: 'DAY' | 'MONTH' | 'YEAR';
  19. originalPrice: number;
  20. currentPrice: number;
  21. free?: {
  22. number: number;
  23. unit: 'DAY' | 'MONTH' | 'YEAR';
  24. };
  25. }
  26. // UI使用的套餐类型
  27. interface VipPackage {
  28. id: number;
  29. name: string;
  30. price: number;
  31. days: number;
  32. originalPrice: number;
  33. freeText?: string; // 赠送信息
  34. }
  35. export default defineComponent({
  36. name: 'VipPurchaseModal',
  37. props: {
  38. /** 是否有取消按钮 */
  39. hasCancel: {
  40. type: Boolean,
  41. default: false
  42. }
  43. },
  44. emits: ['close', 'success'],
  45. setup(_, { emit }) {
  46. const userStore = useUserStore();
  47. // VIP套餐列表(从API获取)
  48. const vipPackages = ref<VipPackage[]>([]);
  49. const selectedPackageId = ref<number>(0);
  50. const loading = ref(false);
  51. const hasClicked = ref(false); // 防重复点击标记
  52. const pageLoading = ref(true); // 页面初始加载状态
  53. const showQrCode = ref(false);
  54. const qrCodeUrl = ref('');
  55. const orderNo = ref('');
  56. const pollingTimer = ref<number | null>(null);
  57. // 支付配置
  58. const paymentConfig = reactive({
  59. paymentType: '',
  60. paymentChannel: '',
  61. wxAppId: ''
  62. });
  63. // 计算总天数
  64. const calculateDays = (item: VipPackageApiItem): number => {
  65. let days = 0;
  66. // 主时长
  67. switch (item.unit) {
  68. case 'DAY':
  69. days += item.number;
  70. break;
  71. case 'MONTH':
  72. days += item.number * 30;
  73. break;
  74. case 'YEAR':
  75. days += item.number * 365;
  76. break;
  77. }
  78. // 赠送时长
  79. if (item.free) {
  80. switch (item.free.unit) {
  81. case 'DAY':
  82. days += item.free.number;
  83. break;
  84. case 'MONTH':
  85. days += item.free.number * 30;
  86. break;
  87. case 'YEAR':
  88. days += item.free.number * 365;
  89. break;
  90. }
  91. }
  92. return days;
  93. };
  94. // 生成赠送信息
  95. const generateFreeText = (item: VipPackageApiItem): string | undefined => {
  96. if (!item.free || item.free.number <= 0) return undefined;
  97. const freeUnit =
  98. item.free.unit === 'DAY'
  99. ? '天'
  100. : item.free.unit === 'MONTH'
  101. ? '个月'
  102. : '年';
  103. return `赠送${item.free.number}${freeUnit}`;
  104. };
  105. // 选中的套餐信息
  106. const currentPackage = computed(
  107. () =>
  108. vipPackages.value.find(p => p.id === selectedPackageId.value) ||
  109. vipPackages.value[0]
  110. );
  111. // 获取支付配置和VIP套餐
  112. const fetchPaymentConfig = async () => {
  113. try {
  114. const { data } = await getPaymentConfig();
  115. if (data && Array.isArray(data)) {
  116. data.forEach((item: any) => {
  117. if (item.paramName === 'payment_service_provider') {
  118. const provider = JSON.parse(item.paramValue);
  119. paymentConfig.paymentType = provider.vendor;
  120. paymentConfig.paymentChannel = provider.channel;
  121. paymentConfig.wxAppId = provider.wxAppId || '';
  122. }
  123. if (
  124. item.paramName === 'teacher_vip_purchase_list' &&
  125. item.paramValue
  126. ) {
  127. const packageList: VipPackageApiItem[] = JSON.parse(
  128. item.paramValue
  129. );
  130. vipPackages.value = packageList.map(pkg => ({
  131. id: pkg.id,
  132. name: pkg.title,
  133. price: pkg.currentPrice,
  134. days: calculateDays(pkg),
  135. originalPrice: pkg.originalPrice,
  136. freeText: generateFreeText(pkg)
  137. }));
  138. // 默认选中中间的那个套餐
  139. const defaultIndex = Math.floor(vipPackages.value.length / 2);
  140. selectedPackageId.value =
  141. vipPackages.value[defaultIndex]?.id || 1;
  142. }
  143. });
  144. }
  145. } catch (e) {
  146. console.error('获取支付配置失败', e);
  147. } finally {
  148. pageLoading.value = false;
  149. }
  150. };
  151. // 开始轮询订单状态
  152. const startPolling = () => {
  153. if (pollingTimer.value) {
  154. clearInterval(pollingTimer.value);
  155. }
  156. pollingTimer.value = window.setInterval(async () => {
  157. try {
  158. const { data } = await getOrderDetail(orderNo.value);
  159. if (data && data.status === 'PAID') {
  160. stopPolling();
  161. window.$message.success('支付成功');
  162. handleCancel();
  163. await userStore.getInfo();
  164. emit('success');
  165. emit('close');
  166. }
  167. } catch (e) {
  168. console.error('轮询订单状态失败', e);
  169. }
  170. }, 3000);
  171. };
  172. // 停止轮询
  173. const stopPolling = () => {
  174. if (pollingTimer.value) {
  175. clearInterval(pollingTimer.value);
  176. pollingTimer.value = null;
  177. }
  178. };
  179. // 取消支付
  180. const handleCancel = () => {
  181. stopPolling();
  182. showQrCode.value = false;
  183. qrCodeUrl.value = '';
  184. orderNo.value = '';
  185. hasClicked.value = false;
  186. };
  187. // 创建订单并生成二维码链接
  188. const handlePurchase = async () => {
  189. if (hasClicked.value || loading.value) return;
  190. hasClicked.value = true;
  191. loading.value = true;
  192. try {
  193. const pkg = currentPackage.value;
  194. const goodsInfos = [
  195. {
  196. goodsId: pkg.id,
  197. goodsName: pkg.name,
  198. goodsPrice: pkg.price,
  199. goodsNum: 1,
  200. goodsType: 'TEACHER_VIP'
  201. }
  202. ];
  203. // 创建订单
  204. const orderRes = await createVipOrder({
  205. orderType: 'TEACHER_VIP',
  206. paymentType: paymentConfig.paymentType,
  207. paymentChannel: paymentConfig.paymentChannel,
  208. paymentCashAmount: pkg.price,
  209. paymentCouponAmount: 0,
  210. goodsInfos,
  211. orderName: '乐器AI学练工具',
  212. orderDesc: '乐器AI学练工具'
  213. });
  214. if (orderRes.data) {
  215. orderNo.value = orderRes.data.orderNo;
  216. const payConfig =
  217. orderRes.data.paymentConfig?.paymentConfig ||
  218. orderRes.data.paymentConfig;
  219. // 生成二维码链接,跳转到 classroom-app 的 payDefine 页面
  220. const params = qs.stringify({
  221. pay_channel: paymentConfig.paymentChannel,
  222. wxAppId: payConfig.wxAppId,
  223. alipayAppId: payConfig.alipayAppId || '',
  224. paymentType: paymentConfig.paymentType,
  225. body: '乐器AI学练工具',
  226. price: pkg.price,
  227. orderNo: payConfig.merOrderNo || orderNo.value,
  228. userId: payConfig.userId
  229. });
  230. // getHttpOrigin() +
  231. qrCodeUrl.value =
  232. getHttpOrigin() + '/classroom-app/#/payDefine?' + params;
  233. console.log(qrCodeUrl.value);
  234. showQrCode.value = true;
  235. console.log(qrCodeUrl.value, 'value');
  236. startPolling();
  237. }
  238. } catch (e: any) {
  239. hasClicked.value = false;
  240. window.$message.error(e.msg || '创建订单失败');
  241. } finally {
  242. loading.value = false;
  243. }
  244. };
  245. // 初始化获取支付配置
  246. fetchPaymentConfig();
  247. // 组件卸载时清理
  248. onUnmounted(() => {
  249. stopPolling();
  250. });
  251. // 计算会员是否过期
  252. const membershipEndTime = userStore.getUserInfo?.membershipEndTime;
  253. const isVipExpired = computed(() => {
  254. return !membershipEndTime || new Date(membershipEndTime) < new Date();
  255. });
  256. return () => (
  257. <NSpin show={pageLoading.value} class={styles.spinWrap}>
  258. <div class={styles.vipPurchaseModal}>
  259. {!showQrCode.value ? (
  260. <div>
  261. {isVipExpired.value && (
  262. <div class={styles.subtitle}>
  263. 您的会员已过期,请续费后继续使用
  264. </div>
  265. )}
  266. <div class={styles.packageList}>
  267. {vipPackages.value.map((pkg: VipPackage) => (
  268. <div
  269. key={pkg.id}
  270. class={[
  271. styles.packageItem,
  272. selectedPackageId.value === pkg.id ? styles.selected : ''
  273. ]}
  274. onClick={() => (selectedPackageId.value = pkg.id)}
  275. >
  276. <div class={styles.packageName}>{pkg.name}</div>
  277. <div class={styles.freeText}>{pkg.freeText || ''}</div>
  278. <div class={styles.packagePrice}>
  279. <span class={styles.priceAmount}>{pkg.price}</span>
  280. <del class={styles.originalPrice}>
  281. ¥{pkg.originalPrice}
  282. </del>
  283. </div>
  284. </div>
  285. ))}
  286. </div>
  287. <div class={styles.btnGroup}>
  288. <NButton round size="large" onClick={() => emit('close')}>
  289. 取消
  290. </NButton>
  291. <NButton
  292. round
  293. size="large"
  294. type="primary"
  295. loading={loading.value}
  296. disabled={loading.value || hasClicked.value}
  297. onClick={handlePurchase}
  298. >
  299. 续费 ¥{currentPackage.value?.price}
  300. </NButton>
  301. </div>
  302. </div>
  303. ) : (
  304. <div class={styles.qrCodeSection}>
  305. <div class={styles.qrCodeTitle}>扫码支付</div>
  306. <div class={styles.qrCodeWrap}>
  307. {qrCodeUrl.value && (
  308. <TheQrCode text={qrCodeUrl.value} size={200} />
  309. )}
  310. <div class={styles.payTip}>请使用微信扫描二维码完成支付</div>
  311. <div class={styles.orderInfo}>订单号:{orderNo.value}</div>
  312. </div>
  313. <div class={styles.btnGroup}>
  314. <NButton round size="large" onClick={handleCancel}>
  315. 取消支付
  316. </NButton>
  317. </div>
  318. </div>
  319. )}
  320. </div>
  321. </NSpin>
  322. );
  323. }
  324. });