Files
yunzerwebsiteallinone/backend/src/views/product/index.vue
T

592 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="product-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="8" animated />
<div v-else-if="!product" 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>
<template v-else>
<!-- 产品头部 -->
<section class="hero">
<div class="hero-main">
<h1 class="hero-title">{{ product.name }}</h1>
<p class="hero-code">产品编码:{{ product.code }}</p>
</div>
<div class="hero-price">
<span class="price-symbol">¥</span>
<span class="price-value">{{ Number(product.price || 0).toFixed(2) }}</span>
</div>
</section>
<!-- 功能节点树 -->
<section v-if="featureTree.length > 0" class="section">
<h2 class="section-title">功能清单</h2>
<div class="feature-box">
<el-tree
:data="featureTree"
:props="{ label: 'title', children: 'children' }"
node-key="key"
default-expand-all
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-title">{{ data.title }}</span>
<span v-if="data.description" class="node-desc">{{ data.description }}</span>
</div>
</template>
</el-tree>
</div>
</section>
<!-- 结算区 -->
<section class="section">
<div class="checkout">
<div class="checkout-info">
<span class="checkout-label">应付金额:</span>
<span class="checkout-amount">¥{{ Number(product.price || 0).toFixed(2) }}</span>
</div>
<div v-if="channels.length > 0" class="checkout-pay">
<span class="checkout-label">支付方式:</span>
<el-radio-group v-model="channel">
<el-radio v-for="c in channels" :key="c.channel" :value="c.channel">
{{ c.name || c.channel }}
</el-radio>
</el-radio-group>
</div>
<el-button type="primary" size="large" :loading="submitting" @click="handleBuy">
立即购买
</el-button>
</div>
<p v-if="channels.length === 0" class="checkout-tip">
平台暂未配置在线支付渠道,点击「立即购买」将提交购买申请,由平台确认后开通。
</p>
</section>
</template>
</div>
<!-- 支付弹窗 -->
<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(product?.price || 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>
<div v-else class="pay-body">
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
<p class="pay-tip">购买申请已提交,请等待平台确认开通</p>
</div>
<p class="pay-status">{{ payStatusText }}</p>
<template #footer>
<el-button @click="handleCancelPay">取消支付</el-button>
<el-button type="primary" :loading="checking" @click="checkOrder">我已完成支付</el-button>
</template>
</el-dialog>
<!-- 我的订单 -->
<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>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft, OfficeBuilding, Box, CircleCheckFilled } from "@element-plus/icons-vue";
import QRCode from "qrcode";
import {
getProductDetailByCode,
createProductOrder,
getProductOrderStatus,
getMyProductOrders,
getPaymentChannels,
} from "@/api/product";
import { useAuthStore } from "@/stores/auth";
import { reloadMenusAndDynamicRoutes } from "@/router";
interface FeatureNode {
key: number;
title: string;
description: string;
children?: FeatureNode[];
}
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const product = ref<any>(null);
const channels = ref<any[]>([]);
const channel = ref("");
const submitting = ref(false);
const payVisible = ref(false);
const payParams = ref<any>(null);
const orderNo = ref("");
const checking = ref(false);
const payStatusText = ref("");
const qrRef = ref<HTMLCanvasElement | null>(null);
let pollTimer: any = null;
const ordersVisible = ref(false);
const myOrders = ref<any[]>([]);
const ordersLoading = ref(false);
const tenantName = computed(() => authStore.user?.tenant_name || "");
/** 扁平功能节点 -> 树 */
const featureTree = computed<FeatureNode[]>(() => {
const list: any[] = product.value?.features || [];
const map = new Map<number, FeatureNode>();
const roots: FeatureNode[] = [];
list.forEach((f) =>
map.set(Number(f.id), {
key: Number(f.id),
title: f.title || "",
description: f.description || "",
children: [],
})
);
list.forEach((f) => {
const node = map.get(Number(f.id))!;
const parent = f.pid ? map.get(Number(f.pid)) : undefined;
if (parent) parent.children!.push(node);
else roots.push(node);
});
return roots;
});
const currentChannelName = computed(() => {
const c = channels.value.find((x) => x.channel === channel.value);
return c?.name || channel.value;
});
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 loadProduct = async () => {
const code = String(route.params.code || "").trim();
if (!code) {
product.value = null;
return;
}
loading.value = true;
try {
const res = await getProductDetailByCode(code);
product.value = res.code === 200 && res.data ? res.data : null;
} catch {
product.value = null;
} finally {
loading.value = false;
}
};
const loadChannels = async () => {
try {
const res = await getPaymentChannels();
if (res.code === 200) {
channels.value = res.data?.list || [];
if (channels.value.length > 0) channel.value = channels.value[0].channel;
}
} catch {
channels.value = [];
}
};
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 handleBuy = async () => {
if (!product.value) return;
submitting.value = true;
try {
const res = await createProductOrder({
product_code: product.value.code,
channel: channels.value.length > 0 ? channel.value : "",
pay_type: "",
// 支付宝同步回跳地址:必须是完整 http(s) 地址,且不能带 # 和自定义参数,
// 否则支付成功后收银台倒计时结束无处可跳、页面卡住,故取站点根路径
return_url: window.location.origin + window.location.pathname,
});
if (res.code !== 200) {
ElMessage.error(res.msg || "下单失败");
return;
}
orderNo.value = res.data?.order_no || "";
payParams.value = res.data?.pay_params || null;
payStatusText.value = res.data?.offline
? "购买申请已提交,请等待平台确认开通"
: "请在有效期内完成支付,支付成功后由平台开通功能";
payVisible.value = true;
await renderQR();
if (payParams.value?.redirect_url) openPayUrl(payParams.value.redirect_url);
if (!res.data?.offline) startPolling();
} catch (e: any) {
ElMessage.error(e?.message || "下单失败");
} finally {
submitting.value = false;
}
};
const startPolling = () => {
stopPolling();
pollTimer = setInterval(() => checkOrder(true), 5000);
};
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
const checkOrder = async (silent = false) => {
if (!orderNo.value) return;
checking.value = true;
try {
const res = await getProductOrderStatus(orderNo.value);
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;
}
payStatusText.value = silent
? payStatusText.value || "等待支付…"
: "尚未检测到支付成功,请确认已完成支付";
if (!silent) ElMessage.info("尚未检测到支付成功");
}
} catch {
if (!silent) ElMessage.error("查询订单失败");
} finally {
checking.value = false;
}
};
const handleCancelPay = () => {
stopPolling();
payVisible.value = false;
ElMessage.info("已取消,订单仍可在「我的订单」中查看");
};
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;
}
};
watch(ordersVisible, (v) => {
if (v) loadMyOrders();
});
watch(payVisible, (v) => {
if (!v) stopPolling();
});
onMounted(() => {
loadProduct();
loadChannels();
});
</script>
<style scoped lang="less">
.product-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: 1000px;
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: 1000px;
margin: 0 auto;
padding: 24px;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 14px;
padding: 28px;
color: #fff;
.hero-title {
margin: 0 0 8px;
font-size: 26px;
font-weight: 700;
}
.hero-code {
margin: 0;
font-size: 13px;
opacity: 0.85;
}
.hero-price {
display: flex;
align-items: baseline;
gap: 2px;
.price-symbol {
font-size: 18px;
}
.price-value {
font-size: 34px;
font-weight: 700;
}
}
}
.section {
margin-top: 28px;
.section-title {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
margin: 0 0 16px;
padding-left: 8px;
border-left: 4px solid #667eea;
}
}
.feature-box {
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
padding: 16px 18px;
.tree-node {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
.node-title {
font-size: 14px;
color: #1a1a2e;
}
.node-desc {
font-size: 12px;
color: #909399;
}
}
}
.checkout {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
background: #fff;
border-radius: 12px;
padding: 18px 22px;
border: 1px solid #ebeef5;
.checkout-info {
display: flex;
align-items: center;
gap: 8px;
.checkout-amount {
color: #f56c6c;
font-size: 24px;
font-weight: 700;
}
}
.checkout-pay {
display: flex;
align-items: center;
gap: 8px;
}
.checkout-label {
color: #909399;
}
}
.checkout-tip {
margin: 10px 0 0;
font-size: 12px;
color: #909399;
}
.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;
margin: 10px 0 0;
}
.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;
}
.hero {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.checkout {
flex-direction: column;
align-items: stretch;
}
}
</style>