Files
yunzerwebsiteallinone/backend/src/views/package/list.vue
T

563 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="package-list-page">
<!-- 顶部栏 -->
<div class="page-top">
<div class="toolbar">
<div class="toolbar-left">
<el-button link class="back-btn" @click="goHome">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<span v-if="tenantName" class="tenant-name">
<el-icon><OfficeBuilding /></el-icon>
{{ tenantName }}
</span>
</div>
<div class="toolbar-right">
<el-button link @click="ordersVisible = true">我的订单</el-button>
</div>
</div>
</div>
<div class="page-body">
<el-skeleton v-if="loading" :rows="6" animated />
<div v-else class="package-grid">
<el-card
v-for="pkg in packages"
:key="pkg.id"
class="package-card"
shadow="hover"
>
<div class="card-header">
<div class="header-main">
<h3 class="package-name">{{ pkg.name }}</h3>
<el-tag size="small" type="info" effect="plain">{{ pkg.code }}</el-tag>
</div>
<div class="package-price">
<span class="price-symbol">¥</span>
<span class="price-value">{{ Number(pkg.price || 0).toFixed(2) }}</span>
<span class="price-unit">/ {{ durationText(pkg.duration_days) }}</span>
</div>
</div>
<div class="card-divider"></div>
<!-- 功能模块 -->
<div v-if="pkg.modules && pkg.modules.length > 0" class="package-features">
<span class="feature-label">包含功能:</span>
<el-tag
v-for="m in pkg.modules"
:key="m.module_code"
size="small"
effect="plain"
class="feature-tag"
>
{{ m.module_name || m.module_code }}
</el-tag>
</div>
<!-- 用户数配额 -->
<div class="package-quota">
<el-icon :size="14"><User /></el-icon>
<span>用户数上限:{{ pkg.user_quota || 20 }} 人</span>
<el-tooltip
v-if="pkg.extra_user_price"
content="超出上限后,按单价增购"
placement="top"
>
<el-icon :size="14" class="extra-price-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
<!-- 备注 -->
<p v-if="pkg.remark" class="package-remark">{{ pkg.remark }}</p>
<!-- 购买按钮 -->
<div class="card-footer">
<el-button
type="primary"
size="large"
:loading="buyingIds[pkg.id]"
:disabled="isPurchasedOnce(pkg)"
@click="handleBuy(pkg)"
class="buy-btn"
>
{{ isPurchasedOnce(pkg) ? "已购买(限购一次)" : "立即购买" }}
</el-button>
</div>
</el-card>
</div>
<div v-if="!loading && packages.length === 0" class="empty-state">
<el-icon :size="64"><Box /></el-icon>
<p class="empty-text">暂无可购买套餐</p>
<p class="empty-tip">请联系平台管理员配置套餐后购买</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</div>
<!-- 我的订单 -->
<el-dialog v-model="ordersVisible" title="我的订单" width="720px">
<el-table :data="myOrders" size="small" v-loading="ordersLoading">
<el-table-column prop="order_no" label="订单号" min-width="160" show-overflow-tooltip />
<el-table-column prop="product_name" label="套餐" min-width="160" show-overflow-tooltip />
<el-table-column label="金额" width="100" align="center">
<template #default="{ row }">¥{{ Number(row.amount || 0).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="orderStatusType(row.status)" size="small">
{{ orderStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="下单时间" width="170" align="center" />
</el-table>
</el-dialog>
<!-- 支付弹窗 -->
<el-dialog v-model="payVisible" title="扫码支付" width="420px" :close-on-click-modal="false">
<div v-if="payParams?.code_url" class="pay-body">
<canvas ref="qrRef" class="pay-qr"></canvas>
<p class="pay-tip">请使用{{ currentChannelName }}扫码支付</p>
<p class="pay-amount">¥{{ Number(payAmount || 0).toFixed(2) }}</p>
</div>
<div v-else-if="payParams?.redirect_url" class="pay-body">
<p class="pay-tip">正在跳转{{ currentChannelName }}收银台…</p>
<el-button type="primary" @click="openPayUrl(payParams.redirect_url)">重新打开支付页</el-button>
</div>
<p class="pay-status">{{ payStatusText }}</p>
<template #footer>
<el-button @click="handleCancelPay">取消支付</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft, OfficeBuilding, Box, User, InfoFilled } from "@element-plus/icons-vue";
import QRCode from "qrcode";
import { useAuthStore } from "@/stores/auth";
import { reloadMenusAndDynamicRoutes } from "@/router";
import {
getPackageList,
createPackageOrder,
getProductOrderStatus,
getMyProductOrders,
getPaymentChannels,
} from "@/api/product";
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const packages = ref<any[]>([]);
const buyingIds = ref<Record<string, boolean>>({});
const payVisible = ref(false);
const payParams = ref<any>(null);
const payStatusText = ref("");
const payAmount = ref(0);
const currentChannelName = ref("");
const qrRef = ref<HTMLCanvasElement | null>(null);
const ordersVisible = ref(false);
const myOrders = ref<any[]>([]);
const ordersLoading = ref(false);
const tenantName = computed(() => authStore.user?.tenant_name || "");
const durationText = (days: number) => {
if (!days) return "年";
if (days === 365) return "年";
if (days === 30) return "月";
if (days === 90) return "季";
return `${days}天`;
};
const orderStatusText = (s: string) =>
({ pending: "待支付", paid: "已支付", activated: "已开通", closed: "已关闭" }[s] || s);
const orderStatusType = (s: string) =>
({ pending: "warning", paid: "primary", activated: "success", closed: "info" }[s] || "info");
const goHome = () => router.push("/home");
const loadPackages = async () => {
loading.value = true;
try {
const res = await getPackageList();
packages.value = res.code === 200 && res.data ? res.data.list || [] : [];
} catch {
packages.value = [];
} finally {
loading.value = false;
}
};
const loadMyOrders = async () => {
ordersLoading.value = true;
try {
const res = await getMyProductOrders();
if (res.code === 200) myOrders.value = res.data?.list || [];
} catch {
myOrders.value = [];
} finally {
ordersLoading.value = false;
}
};
const openPayUrl = (url: string) => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
};
const renderQR = async () => {
if (!payParams.value?.code_url) return;
await nextTick();
if (!qrRef.value) return;
try {
await QRCode.toCanvas(qrRef.value, payParams.value.code_url, { width: 200, margin: 1 });
} catch (e) {
console.error("二维码生成失败:", e);
}
};
/** 仅可购买一次的套餐(体验套餐)在已购买过之后禁用购买按钮 */
const isPurchasedOnce = (pkg: any) =>
Number(pkg?.allow_repurchase) === 0 && pkg?.purchased === true;
const handleBuy = async (pkg: any) => {
buyingIds.value[pkg.id] = true;
try {
// 先获取可用支付渠道
const channelRes = await getPaymentChannels();
const channels = channelRes.code === 200 ? channelRes.data?.list || [] : [];
const channel = channels.length > 0 ? channels[0].channel : "";
const res = await createPackageOrder({
package_code: pkg.code,
channel,
pay_type: "",
// 支付宝同步回跳地址:必须是完整 http(s) 地址,且不能带 # 和自定义参数,
// 否则支付成功后收银台倒计时结束无处可跳、页面卡住,故取站点根路径
return_url: window.location.origin + window.location.pathname,
});
if (res.code !== 200) {
ElMessage.error(res.msg || "下单失败");
return;
}
const orderNo = res.data?.order_no || "";
const offline = res.data?.offline === true;
if (offline) {
ElMessage.success("购买申请已提交,请等待平台确认开通");
loadMyOrders();
return;
}
// 在线支付:展示扫码二维码或跳转收银台,并轮询订单状态
payParams.value = res.data?.pay_params || null;
payAmount.value = Number(res.data?.amount || pkg.price || 0);
currentChannelName.value = channels.find((x: any) => x.channel === channel)?.name || channel;
payStatusText.value = "请在有效期内完成支付,支付成功后由平台开通功能";
payVisible.value = true;
await renderQR();
if (payParams.value?.redirect_url) openPayUrl(payParams.value.redirect_url);
startPolling(orderNo);
} catch (e: any) {
ElMessage.error(e?.message || "下单失败");
} finally {
buyingIds.value[pkg.id] = false;
}
};
let pollTimer: any = null;
const startPolling = (orderNo: string) => {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
checkOrder(orderNo, true);
}, 5000);
// 立即检查一次
checkOrder(orderNo, true);
};
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
const checkOrder = async (orderNo: string, silent = false) => {
try {
const res = await getProductOrderStatus(orderNo);
if (res.code === 200 && res.data) {
const status = res.data.status;
if (status === "activated") {
stopPolling();
ElMessage.success("购买成功,套餐已开通");
payVisible.value = false;
loadMyOrders();
// 重建动态路由,把新开通模块的「功能未开通」菜单占位页切换为真实页面
reloadMenusAndDynamicRoutes().catch(() => {});
return;
}
if (status === "paid") {
payStatusText.value = "已支付,等待平台开通…";
return;
}
if (!silent) ElMessage.info("尚未检测到支付成功,请确认已完成支付");
}
} catch {
if (!silent) ElMessage.error("查询订单失败");
}
};
const handleCancelPay = () => {
stopPolling();
payVisible.value = false;
ElMessage.info("已取消,订单仍可在「我的订单」中查看");
};
watch(ordersVisible, (v) => {
if (v) loadMyOrders();
});
watch(payVisible, (v) => {
if (!v) stopPolling();
});
onMounted(() => {
loadPackages();
});
</script>
<style scoped lang="less">
.package-list-page {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
}
.page-top {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
z-index: 100;
.toolbar {
max-width: 1200px;
margin: 0 auto;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
.tenant-name {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #606266;
}
}
}
}
.page-body {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.package-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.package-card {
display: flex;
flex-direction: column;
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
.header-main {
flex: 1;
min-width: 0;
.package-name {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.package-price {
display: flex;
align-items: baseline;
gap: 2px;
flex-shrink: 0;
padding-top: 4px;
.price-symbol {
font-size: 16px;
color: #f56c6c;
}
.price-value {
font-size: 28px;
font-weight: 700;
color: #f56c6c;
}
.price-unit {
font-size: 13px;
color: #909399;
margin-left: -4px;
}
}
}
.card-divider {
margin: 0 -20px 12px;
border-bottom: 1px solid #f0f0f0;
}
.package-features {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.feature-label {
font-size: 12px;
color: #909399;
}
.feature-tag {
white-space: nowrap;
}
}
.package-quota {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
margin-bottom: 10px;
.extra-price-icon {
color: #909399;
cursor: help;
}
}
.package-remark {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin: 0 0 16px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #ebeef5;
.buy-btn {
width: 100%;
}
}
}
.pay-body {
text-align: center;
.pay-qr {
width: 200px;
height: 200px;
}
.pay-tip {
font-size: 14px;
color: #606266;
margin: 12px 0 4px;
}
.pay-amount {
font-size: 20px;
font-weight: 700;
color: #f56c6c;
margin: 0;
}
}
.pay-status {
text-align: center;
font-size: 13px;
color: #909399;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: #c0c4cc;
.empty-text {
font-size: 18px;
color: #606266;
}
.empty-tip {
font-size: 14px;
color: #909399;
margin-bottom: 20px;
}
}
@media (max-width: 768px) {
.page-body {
padding: 16px 12px;
}
.package-grid {
grid-template-columns: 1fr;
}
}
</style>