feat(appSubscription): 实现Dashboard订阅到期显示及扫码支付功能
- dashboard订阅到期时间信息支持三态渲染:激活显示剩余天数和已激活标签、待支付显示金额及去支付按钮、无订阅显示立即订阅按钮 - 通过租户ID查询应用产品,实现按租户过滤的订阅展示逻辑 - 新增appProduct分页接口及类型定义,提供应用列表获取能力 - 实现「立即订阅」流程,支持免费应用直接激活,付费应用生成微信扫码支付二维码及轮询订单状态 - 实现待支付订阅复用支付接口生成支付二维码,避免重复创建订阅 - 小程序端扫码支付流程支持订单号查询及支付确认,后端使用Redis标记支付成功状态 - 修复后端generatePayQrcode接口缺少priceType字段导致的数据库报错,前端兼容现有逻辑无需修改 - 移除dashboard中siteInfo旧的expirationTime字段相关代码,改为读订阅expireTime - 新增订阅及支付相关样式,优化界面体验 - 支付状态轮询实现超时提示及自动停止机制,提升用户支付体验 - 组件卸载时确保支付轮询停止,避免内存泄漏 - 独立加载订阅信息,不影响其他数据请求,避免页面加载失败影响订阅展示
This commit is contained in:
@@ -60,3 +60,21 @@
|
||||
- `ShopOrderService` 接口 + `ShopOrderServiceImpl` 实现方法签名加 `paymentVoucher` 参数,lambdaUpdate 加 `.set(paymentVoucher 非空, ShopOrder::getPaymentVoucher, paymentVoucher)`。
|
||||
- DDL 脚本 `sql/shop_order_payment_voucher.sql`:`ALTER TABLE shop_order ADD COLUMN payment_voucher VARCHAR(500) NULL ... AFTER pay_time`。
|
||||
- 注意:方法签名从 2 参变 3 参,全项目仅 Controller 一处调用,无断裂。
|
||||
|
||||
## Dashboard 到期时间显示订阅信息 + 立即订阅/扫码支付
|
||||
|
||||
- 改造 `src/views/shop/dashboard/index.vue` 基本信息「到期时间」行:三态渲染(active 显示 expireTime + 已激活 tag + ≤7天到期提醒;pending 显示待支付 + 去支付;无订阅显示立即订阅)。
|
||||
- **productId 关联条件**:`app_product.tenantId = userStore.info.tenantId`(非 siteInfo.appId)。通过 `pageProducts({tenantId,current:1,size:1})` 取该租户应用。
|
||||
- 新增 `src/api/app/appProduct/{model.ts,index.ts}`:AppProduct 类型 + `pageProducts` 接口(GET /api/app/product/page,分页参数 current/size,注意与项目 page/limit 不同)。
|
||||
- 「立即订阅」流程:选 month/year → 免费应用调 `subscribe()` 直接激活;付费应用调 `generatePayQrcode()` 生成小程序码 → Modal 展示二维码 → 每 2.5s 轮询 `checkStatus(subscriptionNo)`,paid 后关闭+刷新(最多 5 分钟)。envVersion 按 `import.meta.env.DEV` 自动判断 trial/release。
|
||||
- pending 订阅「去支付」复用 `subscriptionStore.pay(id,'wechat')`(用 `'miniappQrcode' in result` 类型守卫窄化)生成小程序码,不重复创建订阅。
|
||||
- 小程序端支付页 `websopy-taro/src/passport/pay/index.tsx`:扫码 scene=subscriptionNo → detail-by-no → mp-prepay → requestPayment → mp-confirm,后端写 Redis `wxpay:paid:{no}=1`,Web 端 checkStatus 据此感知。
|
||||
- 移除 dashboard 不再使用的 `siteInfo` 解构(原到期时间读 `siteInfo.expirationTime`,已改为订阅 expireTime),修复 noUnusedLocals 报错。
|
||||
|
||||
## 修复后端 generatePayQrcode 报错 "Field 'price_type' doesn't have a default value"
|
||||
|
||||
- 现象:dashboard 点「立即订阅」付费应用调 `generatePayQrcode` 时,后端 insert app_subscription 报 `price_type` 无默认值。
|
||||
- 根因:后端 `AppSubscriptionController.generatePayQrcode`(websopy-java)创建订阅时漏了 `setPriceType()`,而 DB `price_type` 为 NOT NULL 无默认值。对比 `subscribe` 方法有 `setPriceType`。
|
||||
- 参考 `websopy-pc/app/pages/console/pay/[subscriptionNo].vue`:它用 `subscribe` 创建订阅 + `pay(id,'wechat')` 生成二维码(两步),避开了 `generatePayQrcode`,所以没遇到此 bug。
|
||||
- 修复(后端 `AppSubscriptionController.java` generatePayQrcode 方法):补全 `setPriceType(product.getPriceType())` + `setOriginalPrice`/`setPayStatus(0)`/`setTenantId`,对齐 subscribe。**前端无需改动**,重启后端即可。
|
||||
- 注意:`subscribe` 价格计算用 `price/100`(当分转元),`generatePayQrcode` 直接用 `price`(当元),两者不一致;schema 标注 price 为「元」,故 `generatePayQrcode` 价格逻辑更合理,保留不动。
|
||||
|
||||
43
src/api/app/appProduct/index.ts
Normal file
43
src/api/app/appProduct/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult, PageResult } from '@/api';
|
||||
import type { AppProduct, AppProductQueryParam } from './model';
|
||||
|
||||
/**
|
||||
* websopy 特殊接口域名
|
||||
* 后端 Controller: com.gxwebsoft.app.controller.AppProductController
|
||||
* @RequestMapping("/api/app/product"),后端无 context-path
|
||||
* 完整 URL = https://websopy-api.websoft.top/api/app/product/xxx
|
||||
*/
|
||||
const WEBSOPY_API_BASE = 'https://websopy-api.websoft.top/api';
|
||||
const BASE = `${WEBSOPY_API_BASE}/app/product`;
|
||||
|
||||
/**
|
||||
* 分页查询应用列表
|
||||
* GET /app/product/page
|
||||
* 支持按 tenantId 过滤(管理后台按租户查询应用)
|
||||
* 注意:分页参数为 current/size(非 page/limit)
|
||||
*/
|
||||
export async function pageProducts(
|
||||
params: AppProductQueryParam
|
||||
): Promise<PageResult<AppProduct>> {
|
||||
const res = await request.get<ApiResult<PageResult<AppProduct>>>(
|
||||
`${BASE}/page`,
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data || { list: [], count: 0 };
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用详情
|
||||
* GET /app/product/detail/{id}
|
||||
*/
|
||||
export async function getProductDetail(id: number): Promise<AppProduct> {
|
||||
const res = await request.get<ApiResult<AppProduct>>(`${BASE}/detail/${id}`);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data as AppProduct;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
143
src/api/app/appProduct/model.ts
Normal file
143
src/api/app/appProduct/model.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 应用产品实体(对应表 app_product)
|
||||
* 字段对照后端 com.gxwebsoft.app.entity.AppProduct
|
||||
*/
|
||||
export interface AppProduct {
|
||||
// 应用ID(主键)
|
||||
productId?: number;
|
||||
// 应用名称
|
||||
productName?: string;
|
||||
// 应用标识(唯一)
|
||||
productCode?: string;
|
||||
// 应用密钥
|
||||
productSecret?: string;
|
||||
// 应用类型: 10网站 20微信小程序 30抖音小程序 40百度小程序 50支付宝小程序 60Android 70iOS 80macOS 90Windows 100插件
|
||||
appType?: number;
|
||||
// 应用类型名称(关联查询)
|
||||
appTypeName?: string;
|
||||
// 分类ID
|
||||
categoryId?: number;
|
||||
// 行业类型(父级)
|
||||
industryParent?: string;
|
||||
// 行业类型(子级)
|
||||
industryChild?: string;
|
||||
// 应用Logo
|
||||
logo?: string;
|
||||
// 应用图标
|
||||
icon?: string;
|
||||
// 二维码
|
||||
qrcode?: string;
|
||||
// 应用截图(JSON数组)
|
||||
screenshots?: string;
|
||||
// 应用简介
|
||||
description?: string;
|
||||
// 详细说明
|
||||
content?: string;
|
||||
// 关键词
|
||||
keywords?: string;
|
||||
// 域名
|
||||
domain?: string;
|
||||
// 域名前缀
|
||||
prefix?: string;
|
||||
// 包名/AppID
|
||||
packageName?: string;
|
||||
// 后台地址
|
||||
adminUrl?: string;
|
||||
// API地址
|
||||
apiUrl?: string;
|
||||
// 下载地址
|
||||
downloadUrl?: string;
|
||||
// 版本号
|
||||
version?: string;
|
||||
// 版本: standard标准版 professional专业版 perpetual永久授权
|
||||
edition?: string;
|
||||
// 最低版本要求
|
||||
minVersion?: string;
|
||||
// 定价: free免费 one_time一次性 subscription订阅
|
||||
priceType?: string;
|
||||
// 价格(元)
|
||||
price?: number;
|
||||
// 划线价格
|
||||
linePrice?: number;
|
||||
// 续费价格
|
||||
renewPrice?: number;
|
||||
// 交付方式: 1源码 2托管 3授权
|
||||
deliveryMethod?: number;
|
||||
// 计费方式: 1按年 2按月 3一次性
|
||||
chargingMethod?: number;
|
||||
// 订阅周期: month/year
|
||||
subscriptionPeriod?: string;
|
||||
// 发布状态: developing pending_review published rejected deprecated
|
||||
publishStatus?: string;
|
||||
// 发布时间
|
||||
publishTime?: string;
|
||||
// 审核时间
|
||||
reviewTime?: string;
|
||||
// 审核人ID
|
||||
reviewerId?: number;
|
||||
// 拒绝原因
|
||||
rejectReason?: string;
|
||||
// 浏览次数
|
||||
clicks?: number;
|
||||
// 安装次数
|
||||
installs?: number;
|
||||
// 下载次数
|
||||
downloads?: number;
|
||||
// 评分(1-5)
|
||||
rating?: number;
|
||||
// 点赞数
|
||||
likes?: number;
|
||||
// 开发者
|
||||
developer?: string;
|
||||
// 开发者电话
|
||||
developerPhone?: string;
|
||||
// 开发者邮箱
|
||||
developerEmail?: string;
|
||||
// 是否推荐: 0否 1是
|
||||
recommend?: number;
|
||||
// 是否官方: 0否 1是
|
||||
official?: number;
|
||||
// 是否上架市场: 0否 1是
|
||||
market?: number;
|
||||
// 是否显示首页: 0否 1是
|
||||
showIndex?: number;
|
||||
// 是否可搜索: 0否 1是
|
||||
searchEnabled?: number;
|
||||
// 模板ID
|
||||
templateId?: number;
|
||||
// 租户ID
|
||||
tenantId?: number;
|
||||
// 创建时间
|
||||
createTime?: string;
|
||||
// 更新时间
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用产品分页查询参数
|
||||
* 注意:后端 AppProductController.page 的分页参数为 current/size(非 page/limit)
|
||||
*/
|
||||
export interface AppProductQueryParam {
|
||||
// 页码
|
||||
current?: number;
|
||||
// 每页条数
|
||||
size?: number;
|
||||
// 应用名称
|
||||
productName?: string;
|
||||
// 应用标识
|
||||
productCode?: string;
|
||||
// 应用类型
|
||||
appType?: number;
|
||||
// 分类ID
|
||||
categoryId?: number;
|
||||
// 发布状态
|
||||
publishStatus?: string;
|
||||
// 状态
|
||||
status?: number;
|
||||
// 用户ID
|
||||
userId?: number;
|
||||
// 租户ID(按租户查询应用)
|
||||
tenantId?: number;
|
||||
// 关键词搜索(同时搜索应用名称和应用标识)
|
||||
keywords?: string;
|
||||
}
|
||||
@@ -168,7 +168,27 @@
|
||||
{{ tenantStore.company?.createTime || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="到期时间">
|
||||
{{ siteInfo?.expirationTime || '-' }}
|
||||
<template v-if="subscriptionLoading">
|
||||
<a-skeleton-input :active="true" size="small" style="width: 140px" />
|
||||
</template>
|
||||
<template v-else-if="currentSubscription && currentSubscription.status === 'active' && currentSubscription.expireTime">
|
||||
<span>{{ formatDateTime(currentSubscription.expireTime) }}</span>
|
||||
<a-tag color="green" style="margin-left: 8px">已激活</a-tag>
|
||||
<span v-if="daysToExpire !== null && daysToExpire >= 0 && daysToExpire <= 7" class="expire-warn">
|
||||
({{ daysToExpire }}天后到期)
|
||||
</span>
|
||||
<span v-else-if="daysToExpire !== null && daysToExpire < 0" class="expire-warn">
|
||||
(已过期)
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="currentSubscription && currentSubscription.status === 'pending'">
|
||||
<span class="text-orange">待支付 ¥{{ Number(currentSubscription.payPrice || 0).toFixed(2) }}</span>
|
||||
<a-button type="link" size="small" @click="repayPending" style="padding: 0 0 0 8px">去支付</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-muted">未订阅</span>
|
||||
<a-button type="link" size="small" @click="onSubscribe" style="padding: 0 0 0 8px">立即订阅</a-button>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="系统运行">
|
||||
{{ runDays }} 天
|
||||
@@ -223,6 +243,80 @@
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 订阅 Modal:选择订阅周期 -->
|
||||
<a-modal
|
||||
v-model:open="subscribeModalVisible"
|
||||
title="订阅应用"
|
||||
:confirm-loading="subscribing"
|
||||
ok-text="确认订阅"
|
||||
cancel-text="取消"
|
||||
:mask-closable="false"
|
||||
@ok="confirmSubscribe"
|
||||
>
|
||||
<div class="subscribe-modal-body">
|
||||
<div class="subscribe-product-name">
|
||||
{{ currentProduct?.productName || '应用订阅' }}
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="isFreeProduct"
|
||||
type="success"
|
||||
message="该应用为免费应用,订阅后立即激活"
|
||||
show-icon
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div v-else class="period-options">
|
||||
<div
|
||||
class="period-option"
|
||||
:class="{ active: selectedPeriod === 'month' }"
|
||||
@click="selectedPeriod = 'month'"
|
||||
>
|
||||
<div class="period-option-label">月付</div>
|
||||
<div class="period-option-price">¥{{ monthPrice.toFixed(2) }}/月</div>
|
||||
</div>
|
||||
<div
|
||||
class="period-option"
|
||||
:class="{ active: selectedPeriod === 'year' }"
|
||||
@click="selectedPeriod = 'year'"
|
||||
>
|
||||
<div class="period-option-label">年付</div>
|
||||
<div class="period-option-price">¥{{ yearPrice.toFixed(2) }}/年</div>
|
||||
<div class="period-option-tag">更划算</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 支付 Modal:微信扫码支付 -->
|
||||
<a-modal
|
||||
v-model:open="payModalVisible"
|
||||
title="微信扫码支付"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
@cancel="onPayModalClose"
|
||||
>
|
||||
<div class="pay-modal-body">
|
||||
<div class="pay-product-name">{{ payProductName }}</div>
|
||||
<div class="pay-amount">
|
||||
<span class="pay-amount-label">支付金额</span>
|
||||
<span class="pay-amount-value">¥{{ payPrice.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="pay-qrcode-wrap">
|
||||
<img
|
||||
v-if="payQrcode"
|
||||
:src="payQrcode"
|
||||
alt="支付二维码"
|
||||
class="pay-qrcode-img"
|
||||
/>
|
||||
<a-spin v-else />
|
||||
</div>
|
||||
<div class="pay-tip">
|
||||
<QrcodeOutlined />
|
||||
请使用微信扫描上方二维码完成支付
|
||||
</div>
|
||||
<div class="pay-order-no">订单号:{{ paySubscriptionNo }}</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -240,28 +334,35 @@ import {
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
ClearOutlined,
|
||||
InfoCircleOutlined
|
||||
InfoCircleOutlined,
|
||||
QrcodeOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue/es';
|
||||
import { useSiteStore } from '@/store/modules/site';
|
||||
import { useStatisticsStore } from '@/store/modules/statistics';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { useTenantStore } from '@/store/modules/tenant';
|
||||
import { useAppSubscriptionStore } from '@/store/modules/appSubscription';
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder';
|
||||
import { getShopSettingCategoryValues } from '@/api/shop/shopSetting';
|
||||
import { getCompressedImageUrl } from '@/utils/image';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { removeSiteInfoCache } from '@/api/cms/cmsWebsite';
|
||||
import { pageProducts } from '@/api/app/appProduct';
|
||||
import type { AppProduct } from '@/api/app/appProduct/model';
|
||||
import type { AppSubscription } from '@/api/app/appSubscription/model';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 使用状态管理
|
||||
const siteStore = useSiteStore();
|
||||
const statisticsStore = useStatisticsStore();
|
||||
const userStore = useUserStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const subscriptionStore = useAppSubscriptionStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 从 store 中获取响应式数据
|
||||
const { siteInfo, loading: siteLoading } = storeToRefs(siteStore);
|
||||
const { loading: siteLoading } = storeToRefs(siteStore);
|
||||
const { loading: statisticsLoading } = storeToRefs(statisticsStore);
|
||||
|
||||
// 系统信息
|
||||
@@ -273,6 +374,227 @@ const systemInfo = reactive({
|
||||
server: 'Linux CentOS 7.9',
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 应用订阅相关(数据来源:websopy app_subscription 表)
|
||||
// productId 通过 当前登录用户租户ID 查询 app_product 获得
|
||||
// ============================================================
|
||||
// 当前应用产品(按租户ID查询)
|
||||
const currentProduct = ref<AppProduct | null>(null);
|
||||
// 当前应用的有效订阅
|
||||
const currentSubscription = ref<AppSubscription | null>(null);
|
||||
// 订阅信息加载中
|
||||
const subscriptionLoading = ref(false);
|
||||
|
||||
// 订阅 Modal(选择周期)
|
||||
const subscribeModalVisible = ref(false);
|
||||
const subscribing = ref(false);
|
||||
const selectedPeriod = ref<'month' | 'year'>('month');
|
||||
|
||||
// 支付 Modal(小程序码扫码支付)
|
||||
const payModalVisible = ref(false);
|
||||
const payQrcode = ref('');
|
||||
const payPrice = ref(0);
|
||||
const paySubscriptionNo = ref('');
|
||||
const payProductName = ref('');
|
||||
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollCount = ref(0);
|
||||
const MAX_POLL_COUNT = 120; // 2.5s * 120 = 5分钟
|
||||
|
||||
// 微信小程序环境版本:开发用 trial,生产用 release
|
||||
const envVersion = import.meta.env.DEV ? 'trial' : 'release';
|
||||
|
||||
// 是否免费应用
|
||||
const isFreeProduct = computed(() => {
|
||||
const p = currentProduct.value;
|
||||
if (!p) return false;
|
||||
return p.priceType === 'free' || !p.price || Number(p.price) === 0;
|
||||
});
|
||||
|
||||
// 月付参考价格(元)
|
||||
const monthPrice = computed(() => Number(currentProduct.value?.price) || 0);
|
||||
// 年付参考价格(元,按12个月)
|
||||
const yearPrice = computed(() => monthPrice.value * 12);
|
||||
|
||||
// 距到期天数(负数表示已过期),null 表示无到期时间
|
||||
const daysToExpire = computed(() => {
|
||||
const expire = currentSubscription.value?.expireTime;
|
||||
if (!expire) return null;
|
||||
return dayjs(expire).startOf('day').diff(dayjs().startOf('day'), 'day');
|
||||
});
|
||||
|
||||
// 格式化时间
|
||||
const formatDateTime = (dt?: string) => {
|
||||
if (!dt) return '-';
|
||||
return dayjs(dt).format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
// 加载当前应用的订阅信息
|
||||
const loadSubscriptionInfo = async () => {
|
||||
const tenantId = userStore.info?.tenantId;
|
||||
if (!tenantId) {
|
||||
console.warn('无法获取租户ID,跳过订阅信息加载');
|
||||
return;
|
||||
}
|
||||
subscriptionLoading.value = true;
|
||||
try {
|
||||
// 1. 按租户ID查询应用产品(取第一条)
|
||||
const productRes = await pageProducts({
|
||||
tenantId,
|
||||
current: 1,
|
||||
size: 1
|
||||
});
|
||||
const product = productRes.list?.[0];
|
||||
if (!product || !product.productId) {
|
||||
console.warn('当前租户未找到应用产品');
|
||||
return;
|
||||
}
|
||||
currentProduct.value = product;
|
||||
|
||||
// 2. 拉取我的订阅列表,过滤出该产品的订阅
|
||||
const subRes = await subscriptionStore.fetchMySubscriptions(
|
||||
{ page: 1, limit: 50 },
|
||||
false
|
||||
);
|
||||
const subs = (subRes.list || []).filter(
|
||||
(s) => s.productId === product.productId
|
||||
);
|
||||
// 优先取 active 中 expireTime 最新;否则取 pending 最新
|
||||
const active = subs
|
||||
.filter((s) => s.status === 'active')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
dayjs(b.expireTime).valueOf() - dayjs(a.expireTime).valueOf()
|
||||
)[0];
|
||||
const pending = subs
|
||||
.filter((s) => s.status === 'pending')
|
||||
.sort(
|
||||
(a, b) => dayjs(b.createTime).valueOf() - dayjs(a.createTime).valueOf()
|
||||
)[0];
|
||||
currentSubscription.value = active || pending || null;
|
||||
} catch (e) {
|
||||
console.warn('获取订阅信息失败:', e);
|
||||
} finally {
|
||||
subscriptionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新订阅信息(支付成功/订阅成功后调用)
|
||||
const refreshSubscription = () => {
|
||||
loadSubscriptionInfo().catch(() => {});
|
||||
};
|
||||
|
||||
// 打开订阅 Modal
|
||||
const onSubscribe = () => {
|
||||
if (!currentProduct.value?.productId) {
|
||||
message.warning('应用信息加载中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
selectedPeriod.value = 'month';
|
||||
subscribeModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 确认订阅
|
||||
const confirmSubscribe = async () => {
|
||||
const product = currentProduct.value;
|
||||
if (!product?.productId) return;
|
||||
subscribing.value = true;
|
||||
try {
|
||||
if (isFreeProduct.value) {
|
||||
// 免费应用:直接创建并激活
|
||||
await subscriptionStore.subscribe({
|
||||
productId: product.productId,
|
||||
subscriptionPeriod: selectedPeriod.value
|
||||
});
|
||||
message.success('订阅成功');
|
||||
subscribeModalVisible.value = false;
|
||||
refreshSubscription();
|
||||
return;
|
||||
}
|
||||
// 付费应用:生成支付小程序码
|
||||
const result = await subscriptionStore.generatePayQrcode({
|
||||
productId: product.productId,
|
||||
subscriptionPeriod: selectedPeriod.value,
|
||||
envVersion
|
||||
});
|
||||
paySubscriptionNo.value = result.subscriptionNo;
|
||||
payQrcode.value = result.qrcodeBase64;
|
||||
payPrice.value = Number(result.payPrice) || 0;
|
||||
payProductName.value =
|
||||
result.productName || product.productName || '应用订阅';
|
||||
subscribeModalVisible.value = false;
|
||||
payModalVisible.value = true;
|
||||
// 开始轮询支付状态
|
||||
startPolling(result.subscriptionNo);
|
||||
} catch (e: any) {
|
||||
console.error('创建订阅失败:', e);
|
||||
message.error(e?.message || '创建订阅失败,请重试');
|
||||
} finally {
|
||||
subscribing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重新支付(pending 订阅,复用已有订阅记录生成小程序码)
|
||||
const repayPending = async () => {
|
||||
const sub = currentSubscription.value;
|
||||
if (!sub?.id || !sub.subscriptionNo) return;
|
||||
try {
|
||||
const result = await subscriptionStore.pay(sub.id, 'wechat', envVersion);
|
||||
if ('miniappQrcode' in result) {
|
||||
paySubscriptionNo.value = sub.subscriptionNo;
|
||||
payQrcode.value = result.miniappQrcode;
|
||||
payPrice.value = Number(result.payPrice) || 0;
|
||||
payProductName.value =
|
||||
sub.productName || currentProduct.value?.productName || '应用订阅';
|
||||
payModalVisible.value = true;
|
||||
startPolling(sub.subscriptionNo);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '生成支付码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询支付状态(每 2.5s 查一次,最多 5 分钟)
|
||||
const startPolling = (subscriptionNo: string) => {
|
||||
stopPolling();
|
||||
pollCount.value = 0;
|
||||
pollingTimer.value = setInterval(async () => {
|
||||
pollCount.value++;
|
||||
if (pollCount.value > MAX_POLL_COUNT) {
|
||||
stopPolling();
|
||||
message.warning('支付状态查询超时,如已支付请刷新页面');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await subscriptionStore.checkStatus(subscriptionNo);
|
||||
if (status.paid) {
|
||||
stopPolling();
|
||||
payModalVisible.value = false;
|
||||
message.success('支付成功');
|
||||
refreshSubscription();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('查询支付状态失败:', e);
|
||||
}
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value);
|
||||
pollingTimer.value = null;
|
||||
}
|
||||
pollCount.value = 0;
|
||||
};
|
||||
|
||||
// 关闭支付 Modal
|
||||
const onPayModalClose = () => {
|
||||
stopPolling();
|
||||
payModalVisible.value = false;
|
||||
payQrcode.value = '';
|
||||
paySubscriptionNo.value = '';
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const now = ref(Date.now());
|
||||
let runDaysTimer: ReturnType<typeof setInterval>;
|
||||
@@ -384,6 +706,11 @@ const loadData = async () => {
|
||||
console.warn('获取租户信息失败:', e);
|
||||
});
|
||||
|
||||
// 独立加载订阅信息(到期时间),不受其他请求失败影响
|
||||
loadSubscriptionInfo().catch((e) => {
|
||||
console.warn('加载订阅信息失败:', e);
|
||||
});
|
||||
|
||||
// 独立请求商城Logo,不受其他请求失败影响
|
||||
getShopSettingCategoryValues('basic')
|
||||
.then((values) => {
|
||||
@@ -461,6 +788,8 @@ onUnmounted(() => {
|
||||
// 组件卸载时停止自动刷新
|
||||
statisticsStore.stopAutoRefresh();
|
||||
clearInterval(runDaysTimer);
|
||||
// 停止支付状态轮询
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -624,4 +953,81 @@ onUnmounted(() => {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 到期时间状态 */
|
||||
.text-muted { color: rgba(0,0,0,0.45); }
|
||||
.text-orange { color: #fa8c16; }
|
||||
.expire-warn { color: #ff4d4f; font-size: 12px; margin-left: 4px; }
|
||||
|
||||
/* 订阅 Modal */
|
||||
.subscribe-modal-body { padding: 8px 0; }
|
||||
.subscribe-product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
.period-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.period-option {
|
||||
flex: 1;
|
||||
border: 2px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.period-option:hover { border-color: #69b1ff; }
|
||||
.period-option.active {
|
||||
border-color: #00704A;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.period-option-label { font-size: 14px; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
|
||||
.period-option-price { font-size: 18px; font-weight: 700; color: #00704A; }
|
||||
.period-option-tag {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 8px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 支付 Modal */
|
||||
.pay-modal-body { text-align: center; padding: 8px 0; }
|
||||
.pay-product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
.pay-amount { margin-bottom: 16px; }
|
||||
.pay-amount-label { font-size: 13px; color: rgba(0,0,0,0.45); margin-right: 8px; }
|
||||
.pay-amount-value { font-size: 24px; font-weight: 800; color: #ff4d4f; }
|
||||
.pay-qrcode-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pay-qrcode-img {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pay-tip {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pay-order-no {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user