feat(appSubscription): 实现Dashboard订阅到期显示及扫码支付功能

- dashboard订阅到期时间信息支持三态渲染:激活显示剩余天数和已激活标签、待支付显示金额及去支付按钮、无订阅显示立即订阅按钮
- 通过租户ID查询应用产品,实现按租户过滤的订阅展示逻辑
- 新增appProduct分页接口及类型定义,提供应用列表获取能力
- 实现「立即订阅」流程,支持免费应用直接激活,付费应用生成微信扫码支付二维码及轮询订单状态
- 实现待支付订阅复用支付接口生成支付二维码,避免重复创建订阅
- 小程序端扫码支付流程支持订单号查询及支付确认,后端使用Redis标记支付成功状态
- 修复后端generatePayQrcode接口缺少priceType字段导致的数据库报错,前端兼容现有逻辑无需修改
- 移除dashboard中siteInfo旧的expirationTime字段相关代码,改为读订阅expireTime
- 新增订阅及支付相关样式,优化界面体验
- 支付状态轮询实现超时提示及自动停止机制,提升用户支付体验
- 组件卸载时确保支付轮询停止,避免内存泄漏
- 独立加载订阅信息,不影响其他数据请求,避免页面加载失败影响订阅展示
This commit is contained in:
2026-07-15 14:11:14 +08:00
parent e0d27f67d5
commit 29a18ffbc6
4 changed files with 613 additions and 3 deletions

View File

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