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