Quellcode durchsuchen

客户端首页api对接

“陈博论” vor 9 Monaten
Ursprung
Commit
c80909f840

+ 28 - 0
web/src/api/frontend/home.ts

@@ -0,0 +1,28 @@
+import createAxios from '/@/utils/axios'
+
+// 分类列表
+export function productList() {
+    return createAxios({
+        url: '/api/product.Package/product_list',
+        method: 'get',
+    })
+}
+
+// 套餐列表
+export function list(data:anyObj) {
+  return createAxios({
+      url: '/api/product.Package/list',
+      params:data,
+      method: 'get',
+  })
+}
+
+// 获取套餐详情
+export function detail(data:anyObj) {
+  return createAxios({
+      url: '/api/product.Package/detail',
+      params:data,
+      method: 'get',
+  })
+}
+

+ 41 - 0
web/src/api/frontend/order.ts

@@ -0,0 +1,41 @@
+import createAxios from '/@/utils/axios'
+
+// 创建购买订单
+// {
+// 	"package_id": "1",
+// 	"quantity": "2" //下单数量
+// }
+export function createOrder(data:anyObj) {
+    return createAxios({
+        url: '/api/product.Purchase/createOrder',
+        method: 'post',
+        data:data,
+    })
+}
+
+// 创建支付二维码
+// {
+// 	"order_id": "13",
+// 	"pay_method": "wechat"
+// }
+export function createQrCode(data:anyObj) {
+  return createAxios({
+      url: '/api/product.Payment/createQrCode',
+      method: 'post',
+      data:data,
+  })
+}
+
+// 查询订单支付状态
+// {
+// 	"order_id": "13" ,
+// "order_no": "20231201001"
+// }
+export function checkOrderStatus(data:anyObj) {
+  return createAxios({
+      url: '/api/invitation.order/orderDetail',
+      method: 'get',
+      params:data,
+  })
+}
+

+ 288 - 112
web/src/views/frontend/Order.vue

@@ -9,7 +9,8 @@
           <!-- 左侧:大图区域 -->
           <div class="goods-cover">
             <div class="cover-image">
-              <img :src="goodsInfo.cover" alt="商品封面">
+              <img v-if="goodsInfo.product?.cover" :src="goodsInfo.product.cover" alt="商品封面">
+              <div v-else class="no-image">暂无图片</div>
             </div>
           </div>
 
@@ -21,9 +22,9 @@
                 {{ goodsInfo.name }}
               </h2>
               <div class="goods-tags">
-                <el-tag type="success" size="small">{{ goodsInfo.deliveryType }}</el-tag>
-                <el-tag size="small">已售 {{ goodsInfo.sold }}</el-tag>
-                <el-tag type="warning" size="small">库存 {{ goodsInfo.stockText }}</el-tag>
+                <el-tag type="success" size="small">{{ goodsInfo.type_text }}</el-tag>
+                <el-tag size="small">已售 {{ goodsInfo.sold_count }}</el-tag>
+                <el-tag type="warning" size="small">库存 {{ goodsInfo.stock_text }}</el-tag>
                 <el-tag type="info" size="small">{{ cardTypeText }}</el-tag>
               </div>
             </div>
@@ -39,32 +40,22 @@
               </div>
               <div class="price-desc">
                 <span v-if="isSeatBasedEnterprise">
-                  {{ goodsInfo.comboPeopleNum }} 人席位,总价将按 {{ goodsInfo.comboPrice }} 元/席位计算
+                  企业套餐,按席位计算(单价:{{ goodsInfo.price_text }})
                 </span>
                 <span v-else>
-                  已选择 {{ buyCount }} 件,自动根据数量合计
+                  已选择 {{ buyCount }} 件,自动根据数量合计(单价:{{ goodsInfo.price_text }})
                 </span>
               </div>
             </div>
 
             <!-- 购买数量 -->
-            <div
-              v-if="!isSeatBasedEnterprise"
-              class="field-row quantity-row"
-            >
+            <div v-if="!isSeatBasedEnterprise" class="field-row quantity-row">
               <div class="field-label">
                 <span>购买数量</span>
               </div>
               <div class="field-content quantity-control">
-                <el-input-number
-                  v-model="buyCount"
-                  :min="1"
-                  :max="goodsInfo.stock"
-                  :step="1"
-                  size="small"
-                  controls-position="right"
-                  class="qty-input-number"
-                />
+                <el-input-number v-model="buyCount" :min="1" :max="goodsInfo.stock" :step="1" size="small"
+                  controls-position="right" class="qty-input-number" />
                 <span class="stock-hint">
                   库存剩余:{{ goodsInfo.stock }} 件
                 </span>
@@ -72,24 +63,15 @@
             </div>
 
             <!-- 企业套餐:席位人数 -->
-            <div
-              v-else
-              class="field-row quantity-row"
-            >
+            <div v-else class="field-row quantity-row">
               <div class="field-label">
                 <span>购买席位</span>
               </div>
               <div class="field-content quantity-control">
-                <el-input-number
-                  v-model="goodsInfo.comboPeopleNum"
-                  :min="2"
-                  :step="1"
-                  size="small"
-                  controls-position="right"
-                  class="qty-input-number"
-                />
+                <el-input-number v-model="seatCount" :min="2" :step="1" size="small"
+                  controls-position="right" class="qty-input-number" />
                 <span class="stock-hint">
-                  最少2个席位,当前默认 {{ goodsInfo.comboPeopleNum }} 个
+                  最少2个席位,当前默认 {{ seatCount }} 个
                 </span>
               </div>
             </div>
@@ -100,27 +82,17 @@
                 <span>付款</span>
               </div>
               <div class="field-content pay-methods">
-                <el-button
-                  size="small"
-                  :type="selectedPayMethod === 'alipay' ? 'primary' : 'default'"
-                  :plain="selectedPayMethod !== 'alipay'"
-                  class="pay-btn"
-                  :class="{ active: selectedPayMethod === 'alipay' }"
-                  @click="selectedPayMethod = 'alipay'"
-                >
-<el-icon class="pay-icon alipay">
-  <Coin />
+                <!-- <el-button size="small" :type="selectedPayMethod === 'alipay' ? 'primary' : 'default'"
+                  :plain="selectedPayMethod !== 'alipay'" class="pay-btn"
+                  :class="{ active: selectedPayMethod === 'alipay' }" @click="selectedPayMethod = 'alipay'">
+                  <el-icon class="pay-icon alipay">
+                    <Coin />
                   </el-icon>
                   支付宝
-                </el-button>
-                <el-button
-                  size="small"
-                  :type="selectedPayMethod === 'wechat' ? 'success' : 'default'"
-                  :plain="selectedPayMethod !== 'wechat'"
-                  class="pay-btn"
-                  :class="{ active: selectedPayMethod === 'wechat' }"
-                  @click="selectedPayMethod = 'wechat'"
-                >
+                </el-button> -->
+                <el-button size="small" :type="selectedPayMethod === 'wechat' ? 'success' : 'default'"
+                  :plain="selectedPayMethod !== 'wechat'" class="pay-btn"
+                  :class="{ active: selectedPayMethod === 'wechat' }" @click="selectedPayMethod = 'wechat'">
                   <el-icon class="pay-icon wechat">
                     <ChatDotRound />
                   </el-icon>
@@ -130,12 +102,7 @@
             </div>
 
             <div class="submit-row">
-              <el-button
-                type="primary"
-                size="large"
-                class="submit-btn"
-                @click="handleSubmit"
-              >
+              <el-button type="primary" size="large" class="submit-btn" @click="handleSubmit">
                 立即下单
               </el-button>
             </div>
@@ -144,9 +111,18 @@
       </el-card>
 
       <!-- 创建企业弹框组件 -->
-      <CreateEnterpriseDialog
-        v-model:visible="createEnterpriseVisible"
-        @success="handleEnterpriseCreated"
+      <CreateEnterpriseDialog v-model:visible="createEnterpriseVisible" @success="handleEnterpriseCreated" />
+
+      <!-- 支付二维码弹窗 -->
+      <PayQrCodeDialog
+        v-model:visible="payDialogVisible"
+        :order-info="currentOrderInfo"
+        :qr-code-url="qrCodeUrl"
+        :pay-method="selectedPayMethod"
+        :loading="qrCodeLoading"
+        :pay-status="payStatus"
+        @close="handlePayDialogClose"
+        @confirm="handlePaySuccess"
       />
 
       <!-- 下半部分:宝贝详情 -->
@@ -158,9 +134,7 @@
         </template>
 
         <div class="detail-content">
-          <p v-for="(line, index) in goodsInfo.detailLines" :key="index">
-            {{ line }}
-          </p>
+          <p>{{ goodsInfo.description || '暂无详情' }}</p>
         </div>
       </el-card>
     </div>
@@ -168,88 +142,263 @@
 </template>
 
 <script setup>
-import { computed, onMounted, reactive, ref } from 'vue'
+import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
 import { useRoute } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { ChatDotRound, Coin } from '@element-plus/icons-vue'
+import { ChatDotRound ,Coin} from '@element-plus/icons-vue'
 import Header from './components/header.vue'
 import CreateEnterpriseDialog from './components/CreateEnterpriseDialog.vue'
+import PayQrCodeDialog from './components/PayQrCodeDialog.vue'
+import { detail } from '/@/api/frontend/home'
+import { createOrder, createQrCode, checkOrderStatus } from '/@/api/frontend/order'
 
 defineOptions({ name: 'ClientOrder' })
 
 const route = useRoute()
 
 const goodsId = ref(null)
-const goodsInfo = reactive({
-  name: '惊鸿·日卡',
-  price: '25',
-  sold: 318,
-  stockText: '即将售罄',
-  stock: 5,
-  deliveryType: '自动激活',
-  cover: 'https://picsum.photos/900/400',
-  type: 2, // 1: 个人 2: 企业
-  cardType: 4, // 1: 日卡 2: 周卡 3: 月卡 4: 季卡 5: 年卡 , 6: 按次数 , 7: 按积分
-  comboPeopleNum: 5, // 默认席位
-  comboPrice: 5, // 每席位单价
-  detailLines: [
-    '该卡可续杯 2 次',
-    '该卡可绑定一台设备',
-    '未激活前永久有效',
-    '激活后自下单起 7 日内有效',
-    '下单激活后不予退款,请确认好需要再下单',
-    '在 Iron 工具上使用视为激活',
-    '使用教程:请参考商家提供的使用说明链接'
-  ]
-})
+const goodsInfo = ref({})
 
 const buyCount = ref(1)
+const seatCount = ref(2)
 const hasEnterprise = ref(false)
 const createEnterpriseVisible = ref(false)
-const selectedPayMethod = ref('alipay')
-
-const isEnterprise = computed(() => Number(goodsInfo.type) === 2)
+const selectedPayMethod = ref('wechat')
+
+// 支付相关状态
+const payDialogVisible = ref(false)
+const qrCodeLoading = ref(false)
+const qrCodeUrl = ref('')
+const payStatus = ref('') // '', 'success', 'failed'
+const currentOrderInfo = ref({})
+const payCheckTimer = ref(null)
+const payCheckCount = ref(0)
+const MAX_PAY_CHECK_COUNT = 100 // 最大轮询次数(约5分钟)
+
+const isEnterprise = computed(() => Number(goodsInfo.value?.type) === 2)
 const isSeatBasedEnterprise = computed(() => {
   if (!isEnterprise.value) return false
-  const cardType = Number(goodsInfo.cardType)
+  const cardType = Number(goodsInfo.value?.card_type)
+  // 月卡(6)和年卡(7)不是按席位计算
   return ![6, 7].includes(cardType)
 })
 
+// 获取卡类型文本
 const cardTypeText = computed(() => {
+  if (goodsInfo.value?.card_info?.name) {
+    const cardName = goodsInfo.value.card_info.name
+    const showValue = goodsInfo.value.card_info.show_value
+    return showValue ? `${cardName}(${showValue})` : cardName
+  }
+  // 兼容处理:如果没有card_info,使用映射
   const map = {
-    1: '日卡',
-    2: '周卡',
-    3: '月卡',
-    4: '季卡',
-    5: '年卡',
-    6: '按次数',
-    7: '按积分'
+    1: '次卡',
+    2: '按积分',
+    3: '日卡',
+    4: '周卡',
+    5: '季卡',
+    6: '月卡',
+    7: '年卡'
   }
-  const label = map[Number(goodsInfo.cardType)] || '未知类型'
-  return isEnterprise.value ? `企业·${label}` : `个人·${label}`
+  const label = map[Number(goodsInfo.value?.card_type)] || '未知类型'
+  return label
 })
 
 const displayPrice = computed(() => {
   if (isSeatBasedEnterprise.value) {
-    return (Number(goodsInfo.comboPrice) * Number(goodsInfo.comboPeopleNum)).toFixed(2)
+    return (Number(goodsInfo.value?.price || 0) * seatCount.value).toFixed(2)
   }
-  return (Number(goodsInfo.price) * buyCount.value).toFixed(2)
+  return (Number(goodsInfo.value?.price || 0) * buyCount.value).toFixed(2)
 })
 
+// 获取商品详情
 const getGoodsInfo = () => {
   goodsId.value = route.params?.goodsId
+  detail({id:goodsId.value}).then(res =>{
+    goodsInfo.value = res.data
+  })
+}
+
+// 创建订单并生成支付二维码
+const createOrderAndQrCode = async () => {
+  try {
+    // 1. 创建订单
+    const quantity = isSeatBasedEnterprise.value ? seatCount.value : buyCount.value
+    const orderData = {
+      package_id: goodsId.value,
+      quantity: quantity
+    }
+
+    qrCodeLoading.value = true
+    const orderRes = await createOrder(orderData)
+    
+    // 验证订单创建结果
+    if (!orderRes || !orderRes.data) {
+      ElMessage.error(orderRes?.msg || '创建订单失败,请重试')
+      qrCodeLoading.value = false
+      return
+    }
+    
+    if (!orderRes.data.order_id || !orderRes.data.order_no) {
+      ElMessage.error(orderRes.data.msg || '订单信息不完整,请重试')
+      qrCodeLoading.value = false
+      return
+    }
+
+    const orderId = orderRes.data.order_id
+    // 根据实际返回的数据结构构建订单信息
+    currentOrderInfo.value = {
+      id: orderId,
+      order_id: orderId,
+      order_no: orderRes.data.order_no,
+      title: orderRes.data.package_name || goodsInfo.value.name || '',
+      product_name: orderRes.data.package_name || goodsInfo.value.name || '',
+      package_name: orderRes.data.package_name || '',
+      amount: orderRes.data.total_amount || displayPrice.value,
+      total_price: orderRes.data.total_amount || displayPrice.value,
+      total_amount: orderRes.data.total_amount || 0,
+      quantity: orderRes.data.quantity || (isSeatBasedEnterprise.value ? seatCount.value : buyCount.value),
+      ...orderRes.data
+    }
+
+    // 2. 生成支付二维码
+    const qrCodeData = {
+      order_id: orderId,
+      pay_method: selectedPayMethod.value
+    }
+
+    const qrCodeRes = await createQrCode(qrCodeData)
+    
+    // 验证二维码生成结果
+    if (!qrCodeRes || !qrCodeRes.data) {
+      ElMessage.error(qrCodeRes?.msg || '生成支付二维码失败,请重试')
+      qrCodeLoading.value = false
+      return
+    }
+    
+    if (!qrCodeRes.data.code_url) {
+      ElMessage.error(qrCodeRes.data.msg || '二维码生成失败,请重试')
+      qrCodeLoading.value = false
+      return
+    }
+
+    qrCodeUrl.value = qrCodeRes.data.code_url
+    qrCodeLoading.value = false
+    payStatus.value = ''
+    
+    // 3. 显示支付弹窗
+    payDialogVisible.value = true
+
+    // 4. 开始轮询支付状态
+    startPayStatusPolling(orderId, orderRes.data.order_no)
+  } catch (error) {
+    console.error('下单失败:', error)
+    ElMessage.error(error?.message || '下单失败,请重试')
+    qrCodeLoading.value = false
+  }
 }
 
-const placePersonalOrder = () => {
-  ElMessage.success(`已下单 ${buyCount.value} 件个人套餐`)
+// 开始轮询支付状态
+const startPayStatusPolling = (orderId, orderNo) => {
+  // 清除之前的定时器
+  if (payCheckTimer.value) {
+    clearInterval(payCheckTimer.value)
+  }
+  
+  // 重置轮询计数
+  payCheckCount.value = 0
+
+  // 每3秒查询一次支付状态
+  payCheckTimer.value = setInterval(async () => {
+    try {
+      payCheckCount.value++
+      
+      // 超过最大轮询次数,停止轮询
+      if (payCheckCount.value > MAX_PAY_CHECK_COUNT) {
+        stopPayStatusPolling()
+        ElMessage.warning('支付超时,请稍后手动查询订单状态')
+        return
+      }
+
+      // 根据接口注释,同时传递 order_id 和 order_no
+      const checkData = {
+        order_id: orderId,
+        order_no: orderNo
+      }
+      const res = await checkOrderStatus(checkData)
+      
+      // 支付状态判断:1-已支付,0-待支付,2-支付失败,3-已取消
+      // 兼容不同的数据结构(可能返回 pay_status 或 status 字段)
+      const status = res.data?.pay_status ?? res.data?.status ?? res.data?.order?.pay_status
+      
+      if (status == '1') {
+        // 支付成功
+        payStatus.value = 'success'
+        stopPayStatusPolling()
+        
+        // 更新订单信息(可能包含最新的支付信息)
+        if (res.data) {
+          currentOrderInfo.value = {
+            ...currentOrderInfo.value,
+            ...res.data,
+            pay_status: status
+          }
+        }
+        
+        // 3秒后自动关闭弹窗
+        setTimeout(() => {
+          handlePaySuccess()
+        }, 3000)
+      } else if (status === 2 || status === 3 || status === 'failed' || status === 'cancelled' || status === '2' || status === '3') {
+        // 支付失败或已取消
+        payStatus.value = 'failed'
+        stopPayStatusPolling()
+        ElMessage.warning('订单已取消或支付失败')
+      }
+      // status === 0 或其他值表示待支付,继续轮询
+      // status === 0 或其他值表示待支付,继续轮询
+    } catch (error) {
+      console.error('查询支付状态失败:', error)
+      // 查询失败不中断轮询,继续尝试(除非超过最大次数)
+      if (payCheckCount.value > MAX_PAY_CHECK_COUNT) {
+        stopPayStatusPolling()
+      }
+    }
+  }, 3000)
 }
 
-const placeEnterpriseOrder = () => {
-  if (isSeatBasedEnterprise.value) {
-    ElMessage.success(`已为企业下单 ${goodsInfo.comboPeopleNum} 个席位`)
-  } else {
-    ElMessage.success(`已为企业下单 ${buyCount.value} 件套餐`)
+// 停止支付状态轮询
+const stopPayStatusPolling = () => {
+  if (payCheckTimer.value) {
+    clearInterval(payCheckTimer.value)
+    payCheckTimer.value = null
   }
+  payCheckCount.value = 0
+}
+
+// 支付弹窗关闭处理
+const handlePayDialogClose = () => {
+  stopPayStatusPolling()
+  payDialogVisible.value = false
+  qrCodeUrl.value = ''
+  payStatus.value = ''
+  currentOrderInfo.value = {}
+  qrCodeLoading.value = false
+}
+
+// 支付成功处理
+const handlePaySuccess = () => {
+  stopPayStatusPolling()
+  payDialogVisible.value = false
+  ElMessage.success('支付成功!')
+  
+  // 可以在这里跳转到订单详情页或订单列表页
+  // router.push({ name: 'OrderDetail', params: { id: currentOrderInfo.value.id } })
+  
+  // 重置状态
+  qrCodeUrl.value = ''
+  payStatus.value = ''
+  currentOrderInfo.value = {}
 }
 
 const openCreateEnterpriseDialog = () => {
@@ -261,21 +410,39 @@ const handleEnterpriseCreated = () => {
 }
 
 const handleSubmit = () => {
+  // 验证商品信息
+  if (!goodsId.value || !goodsInfo.value.id) {
+    ElMessage.warning('商品信息加载中,请稍候...')
+    return
+  }
+
+  // 验证库存
+  if (!isSeatBasedEnterprise.value && buyCount.value > goodsInfo.value.stock) {
+    ElMessage.warning('库存不足,请减少购买数量')
+    return
+  }
+
+  // 企业订单需要先创建企业
   if (isEnterprise.value) {
     if (!hasEnterprise.value) {
       ElMessage.warning('请先创建企业后再下单')
       openCreateEnterpriseDialog()
       return
     }
-    placeEnterpriseOrder()
-  } else {
-    placePersonalOrder()
   }
+
+  // 创建订单并生成支付二维码
+  createOrderAndQrCode()
 }
 
 onMounted(() => {
   getGoodsInfo()
 })
+
+// 组件卸载前清除定时器
+onBeforeUnmount(() => {
+  stopPayStatusPolling()
+})
 </script>
 
 <style scoped lang="scss">
@@ -310,6 +477,10 @@ onMounted(() => {
     border-radius: 14px;
     overflow: hidden;
     background: #f2f6fc;
+    min-height: 300px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
 
     img {
       width: 100%;
@@ -317,6 +488,11 @@ onMounted(() => {
       object-fit: cover;
       display: block;
     }
+
+    .no-image {
+      color: #909399;
+      font-size: 14px;
+    }
   }
 }
 

+ 318 - 0
web/src/views/frontend/components/PayQrCodeDialog.vue

@@ -0,0 +1,318 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="扫码支付"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :destroy-on-close="true"
+    class="pay-qrcode-dialog"
+    width="500px"
+    @close="handleClose"
+  >
+    <div class="pay-content">
+      <!-- 订单信息 -->
+      <div class="order-info">
+        <div class="info-item">
+          <span class="label">订单号:</span>
+          <span class="value">{{ orderInfo.order_no || orderInfo.sn || '-' }}</span>
+        </div>
+        <div class="info-item">
+          <span class="label">商品名称:</span>
+          <span class="value">{{ orderInfo.package_name || orderInfo.title || orderInfo.product_name || '-' }}</span>
+        </div>
+        <div class="info-item">
+          <span class="label">购买数量:</span>
+          <span class="value">{{ orderInfo.quantity || '1' }}</span>
+        </div>
+        <div class="info-item">
+          <span class="label">订单金额:</span>
+          <span class="value amount">¥{{ formatAmount(orderInfo.total_amount || orderInfo.amount || orderInfo.total_price || 0) }}</span>
+        </div>
+      </div>
+
+      <!-- 二维码区域 -->
+      <div class="qr-code-box">
+        <div v-if="loading" class="loading-box">
+          <el-icon class="is-loading"><Loading /></el-icon>
+          <p>正在生成二维码...</p>
+        </div>
+        <div v-else-if="qrCodeUrl" class="qr-code-wrapper">
+          <QrcodeVue :value="qrCodeUrl" :size="220" :margin="0" level="H" />
+          <div v-if="payStatus === 'success'" class="pay-success-overlay">
+            <el-icon class="success-icon"><CircleCheck /></el-icon>
+            <p>支付成功</p>
+          </div>
+        </div>
+        <div v-else class="error-box">
+          <el-icon class="error-icon"><CircleClose /></el-icon>
+          <p>二维码生成失败</p>
+        </div>
+      </div>
+
+      <!-- 支付提示 -->
+      <el-alert
+        v-if="qrCodeUrl && payStatus !== 'success'"
+        :closable="false"
+        type="success"
+        class="pay-tips"
+      >
+        <template #default>
+          <div class="tips-content">
+            <el-icon class="tips-icon">
+              <ChatDotRound v-if="payMethod === 'wechat'" />
+              <Coin v-else />
+            </el-icon>
+            <span v-if="payMethod === 'wechat'">请使用微信扫码支付</span>
+            <span v-else>请使用支付宝扫码支付</span>
+          </div>
+        </template>
+      </el-alert>
+
+      <!-- 支付成功提示 -->
+      <el-alert
+        v-if="payStatus === 'success'"
+        :closable="false"
+        type="success"
+        class="pay-success-tips"
+      >
+        <template #default>
+          <div class="tips-content">
+            <el-icon class="tips-icon"><CircleCheck /></el-icon>
+            <span>支付成功,页面将自动跳转</span>
+          </div>
+        </template>
+      </el-alert>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleClose">取消支付</el-button>
+        <el-button v-if="payStatus === 'success'" type="primary" @click="handleConfirm">
+          确定
+        </el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { computed, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Loading, CircleCheck, CircleClose, ChatDotRound, Coin } from '@element-plus/icons-vue'
+import QrcodeVue from 'qrcode.vue'
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  orderInfo: {
+    type: Object,
+    default: () => ({})
+  },
+  qrCodeUrl: {
+    type: String,
+    default: ''
+  },
+  payMethod: {
+    type: String,
+    default: 'wechat'
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  },
+  payStatus: {
+    type: String,
+    default: '' // '', 'success', 'failed'
+  }
+})
+
+const emit = defineEmits(['update:visible', 'close', 'confirm'])
+
+const dialogVisible = computed({
+  get: () => props.visible,
+  set: (val) => emit('update:visible', val)
+})
+
+const handleClose = () => {
+  emit('close')
+  dialogVisible.value = false
+}
+
+const handleConfirm = () => {
+  emit('confirm')
+  dialogVisible.value = false
+}
+
+// 格式化金额显示
+const formatAmount = (amount) => {
+  if (amount === null || amount === undefined || amount === '') {
+    return '0.00'
+  }
+  const num = Number(amount)
+  if (isNaN(num)) {
+    return '0.00'
+  }
+  return num.toFixed(2)
+}
+</script>
+
+<style scoped lang="scss">
+.pay-qrcode-dialog {
+  :deep(.el-dialog__body) {
+    padding: 20px;
+  }
+}
+
+.pay-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 20px;
+}
+
+.order-info {
+  width: 100%;
+  padding: 16px;
+  background: #f5f7fa;
+  border-radius: 8px;
+  
+  .info-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 8px;
+    
+    &:last-child {
+      margin-bottom: 0;
+    }
+    
+    .label {
+      font-size: 14px;
+      color: #606266;
+    }
+    
+    .value {
+      font-size: 14px;
+      color: #303133;
+      font-weight: 500;
+      
+      &.amount {
+        color: #f56c6c;
+        font-size: 18px;
+        font-weight: 600;
+      }
+    }
+  }
+}
+
+.qr-code-box {
+  position: relative;
+  width: 240px;
+  height: 240px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fff;
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+  
+  .loading-box {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    color: #909399;
+    
+    .el-icon {
+      font-size: 32px;
+    }
+    
+    p {
+      margin: 0;
+      font-size: 14px;
+    }
+  }
+  
+  .qr-code-wrapper {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  
+  .pay-success-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background: rgba(255, 255, 255, 0.95);
+    border-radius: 8px;
+    
+    .success-icon {
+      font-size: 48px;
+      color: #67c23a;
+      margin-bottom: 8px;
+    }
+    
+    p {
+      margin: 0;
+      font-size: 16px;
+      color: #67c23a;
+      font-weight: 500;
+    }
+  }
+  
+  .error-box {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    color: #f56c6c;
+    
+    .error-icon {
+      font-size: 32px;
+    }
+    
+    p {
+      margin: 0;
+      font-size: 14px;
+    }
+  }
+}
+
+.pay-tips,
+.pay-success-tips {
+  width: 100%;
+  
+  .tips-content {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    
+    .tips-icon {
+      font-size: 16px;
+    }
+    
+    span {
+      font-size: 14px;
+    }
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>
+

+ 11 - 10
web/src/views/frontend/components/goods.vue

@@ -9,7 +9,7 @@
         >
           <!-- 商品图片 -->
           <div class="goods-image-wrapper">
-            <img :src="item.img" :alt="item.name" class="goods-image">
+            <img :src="item.image" :alt="item.name" class="goods-image">
             <!-- 自动发货标签 -->
             <div class="auto-delivery-badge" :class="item.type === 1 ? 'badge-personal' : 'badge-enterprise'">
               <span>{{ item.type === 1 ? '个人套餐' : '企业套餐' }}</span>
@@ -29,13 +29,13 @@
   
             <!-- 库存和已售信息 -->
             <div class="goods-meta">
-              <div class="inventory-status" :class="getInventoryClass(item.status)">
+              <div class="inventory-status" :class="getInventoryClass(item.stock_text)">
                 <span class="meta-label">库存:</span>
-                <span class="meta-value">{{ getInventoryText(item.status) }}</span>
+                <span class="meta-value">{{ getInventoryText(item.stock_text) }}</span>
               </div>
               <div class="sold-count">
                 <span class="meta-label">已售:</span>
-                <span class="meta-value">{{ item.sold }}</span>
+                <span class="meta-value">{{ item.sold_count }}</span>
               </div>
             </div>
           </div>
@@ -45,15 +45,16 @@
   </template>
   
 <script setup lang="ts">
-type GoodsStatus = '充足' | '一般' | '即将售罄' | '缺货' | string
+type GoodsStatus = '充足' | '一般' | '即将售罄' | '售罄' | string
 
 type GoodsItem = {
   id: string | number
   name: string
-  img: string
+  image: string
   price: number
-  sold: number
-  status: GoodsStatus
+  price_text: string
+  sold_count: number
+  stock_text: GoodsStatus
   type: number
 }
 
@@ -75,7 +76,7 @@ const getInventoryText = (status: GoodsStatus) => {
     充足: '充足',
     一般: '一般',
     即将售罄: '即将售罄',
-    缺货: '缺货'
+    售罄: '售罄'
   }
   return statusMap[status] || status
 }
@@ -84,7 +85,7 @@ const getInventoryClass = (status: GoodsStatus) => {
   if (status === '即将售罄') {
     return 'status-warning'
   }
-  if (status === '缺货') {
+  if (status === '售罄') {
     return 'status-danger'
   }
   if (status === '一般') {

+ 246 - 309
web/src/views/frontend/index.vue

@@ -1,347 +1,284 @@
 <template>
-    <div class="client-home">
-      <!-- 头部 -->
-      <Header />
-      <!-- 购买商品层 -->
-      <div class="home-goods">
-        <el-card class="box-card">
-          <template #header>
-            <div class="clearfix">
-              <span>
-                <el-icon>
-                  <Goods />
-                </el-icon>
-                商品列表
-              </span>
-            </div>
-          </template>
-          <div class="goods-list">
-            <!-- 商品列表 -->
-            <div class="goods-list-option">
-              <div class="option-scroll-wrapper">
-                <div
-                  v-for="(category, index) in goodsCategories"
-                  :key="index"
-                  class="option-item"
-                  :class="{ active: selectedCategory === index }"
-                  @click="selectCategory(index)"
-                >
-                  <div class="option-icon">
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-                      <path
-                        d="M12 2L2 7L12 12L22 7L12 2Z"
-                        stroke="currentColor"
-                        stroke-width="2"
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                      />
-                      <path
-                        d="M2 17L12 22L22 17"
-                        stroke="currentColor"
-                        stroke-width="2"
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                      />
-                      <path
-                        d="M2 12L12 17L22 12"
-                        stroke="currentColor"
-                        stroke-width="2"
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                      />
-                    </svg>
-                  </div>
-                  <span class="option-text">{{ category.name }}</span>
+  <div class="client-home">
+    <!-- 头部 -->
+    <Header />
+    <!-- 购买商品层 -->
+    <div class="home-goods">
+      <el-card class="box-card">
+        <template #header>
+          <div class="clearfix">
+            <span>
+              <el-icon>
+                <Goods />
+              </el-icon>
+              商品列表
+            </span>
+          </div>
+        </template>
+        <div class="goods-list">
+          <!-- 商品列表 -->
+          <div class="goods-list-option">
+            <div class="option-scroll-wrapper">
+              <div v-for="(category, index) in goodsCategories" :key="index" class="option-item"
+                :class="{ active: selectedCategory === index }" @click="selectCategory(index)">
+                <div class="option-icon">
+                  <img class="option-icon-img" :src="category.image" alt="category.name">
                 </div>
+                <span class="option-text">{{ category.name }}</span>
               </div>
             </div>
-  
-            <!-- 商品下的套餐列表 -->
-            <GoodsList :goods-list="goodsList" @handleClick="gotoBuy" />
           </div>
-        </el-card>
-      </div>
+
+          <!-- 商品下的套餐列表 -->
+          <GoodsList :goods-list="goodsList" @handleClick="gotoBuy" />
+        </div>
+      </el-card>
     </div>
-  </template>
-  
+  </div>
+</template>
+
 <script setup lang="ts">
-import { ref } from 'vue'
+import { onMounted, ref } from 'vue'
 import { Goods } from '@element-plus/icons-vue'
-  import { useRouter } from 'vue-router'
-  import Header from './components/header.vue'
-  import GoodsList from './components/goods.vue'
+import { useRouter } from 'vue-router'
+import Header from './components/header.vue'
+import GoodsList from './components/goods.vue'
+import { productList, list } from '/@/api/frontend/home'
 
-  type GoodsCategory = {
-    name: string
-    id: number
-  }
+type GoodsCategory = {
+  name: string
+  id: number
+  package_id:number
+  image:string
+}
 
-  type GoodsItem = {
-    id: string | number
-    img: string
-    name: string
-    price: number
-    sold: number
-    status: string
-    type: number // 类型 1.个人  2.企业
-  }
+type GoodsItem = {
+  id: string | number
+  image: string
+  name: string
+  price: number
+  price_text:string
+  sold_count: number
+  stock_text: string // 库存状态 充足 即将售罄 售罄
+  type: number // 类型 1.个人  2.企业
+}
 
-  const router = useRouter()
-
-  const selectedCategory = ref(0)
-  const goodsCategories = ref<GoodsCategory[]>([
-    { name: '棱镜', id: 1 },
-    { name: '聚德神笔', id: 2 }
-  ])
-  const goodsList = ref<GoodsItem[]>([
-    {
-      id: 1,
-      img: 'https://picsum.photos/200/300',
-      name: '惊鸿·日卡',
-      price: 5.00,
-      sold: 318,
-      status: '即将售罄',
-      type: 1 // 类型 1.个人  2.企业
-    },
-    {
-      id: 2,
-      img: 'https://picsum.photos/200',
-      name: '扶摇·周卡',
-      price: 23.00,
-      sold: 79,
-      status: '即将售罄',
-      type: 1 // 类型 1.个人  2.企业
-    },
-    {
-      id: 3,
-      img: 'https://picsum.photos/200/300',
-      name: '望舒·月卡',
-      price: 52.00,
-      sold: 265,
-      status: '一般',
-      type: 2 // 类型 1.个人  2.企业
-    },
-    {
-      id: 4,
-      img: 'https://picsum.photos/310',
-      name: '惊鸿·日卡',
-      price: 5.00,
-      sold: 318,
-      status: '即将售罄',
-      type: 1 // 类型 1.个人  2.企业
-    },
-    {
-      id: 5,
-      img: 'https://picsum.photos/200',
-      name: '扶摇·周卡',
-      price: 23.00,
-      sold: 79,
-      status: '即将售罄',
-      type: 2 // 类型 1.个人  2.企业
-    },
-    {
-      id: 6,
-      img: 'https://picsum.photos/300',
-      name: '望舒·月卡',
-      price: 52.00,
-      sold: 265,
-      status: '一般',
-      type: 1 // 类型 1.个人  2.企业
+const router = useRouter()
+
+const selectedCategory = ref(0) // 选中分类
+const goodsCategories = ref<GoodsCategory[]>([]) // 分类列表
+const goodsList = ref<GoodsItem[]>([]) // 套餐列表
+
+// 获取分类列表
+const getGoodsCategories = () => {
+  productList().then((res: any) => {
+    goodsCategories.value = res.data
+    getGoodsList(goodsCategories.value[selectedCategory.value].id)
+  })
+}
+
+// 获取套餐列表
+const getGoodsList = (id:number) => {
+  list({ product_id:id }).then((res: any) => {
+    goodsList.value = res.data
+  })
+}
+
+// 选择产品
+const selectCategory = (index: number) => {
+  selectedCategory.value = index
+  getGoodsList(goodsCategories.value[index].id)
+}
+
+// 去下单页面
+const gotoBuy = (item: GoodsItem) => {
+  router.push({
+    name: 'userOrder',
+    params: {
+      goodsId:item.id
     }
-  ])
+  })
+}
 
-  // 选择产品
-  const selectCategory = (index: number) => {
-    selectedCategory.value = index
-    // 这里可以触发商品列表的更新
-    // loadGoods(goodsCategories.value[index].id)
-  }
+// 初始化
+onMounted(() => {
+  getGoodsCategories()
+})
+</script>
 
-  // 去下单页面
-  const gotoBuy = (item: GoodsItem) => {
-    router.push({
-      name: 'userOrder',
-      params: {
-        goodsId: item.id
-      }
-    })
-  }
-  </script>
-  
-  <style scoped lang="scss">
-  .client-home {
-    min-height: 100vh;
-    background: #f5f7fa;
-    display: flex;
-    flex-direction: column;
-  
-    .home-goods {
-      width: 80%;
-      margin: 0 auto;
-      margin-top: 20px;
-  
-      .goods-list {
-        .goods-list-option {
-          margin-bottom: 24px;
-  
-          .option-scroll-wrapper {
-            display: flex;
-            gap: 12px;
-            overflow-x: auto;
-            overflow-y: hidden;
-            padding: 4px 0 12px 0;
-            scrollbar-width: thin;
-            scrollbar-color: rgba(64, 158, 255, 0.3) transparent;
-  
-            // 自定义滚动条样式(Webkit浏览器)
-            &::-webkit-scrollbar {
-              height: 6px;
-            }
-  
-            &::-webkit-scrollbar-track {
-              background: transparent;
-              border-radius: 3px;
-            }
-  
-            &::-webkit-scrollbar-thumb {
-              background: rgba(64, 158, 255, 0.3);
-              border-radius: 3px;
-  
-              &:hover {
-                background: rgba(64, 158, 255, 0.5);
-              }
+<style scoped lang="scss">
+.client-home {
+  min-height: 100vh;
+  background: #f5f7fa;
+  display: flex;
+  flex-direction: column;
+
+  .home-goods {
+    width: 80%;
+    margin: 0 auto;
+    margin-top: 20px;
+
+    .goods-list {
+      .goods-list-option {
+        margin-bottom: 24px;
+
+        .option-scroll-wrapper {
+          display: flex;
+          gap: 12px;
+          overflow-x: auto;
+          overflow-y: hidden;
+          padding: 4px 0 12px 0;
+          scrollbar-width: thin;
+          scrollbar-color: rgba(64, 158, 255, 0.3) transparent;
+
+          // 自定义滚动条样式(Webkit浏览器)
+          &::-webkit-scrollbar {
+            height: 6px;
+          }
+
+          &::-webkit-scrollbar-track {
+            background: transparent;
+            border-radius: 3px;
+          }
+
+          &::-webkit-scrollbar-thumb {
+            background: rgba(64, 158, 255, 0.3);
+            border-radius: 3px;
+
+            &:hover {
+              background: rgba(64, 158, 255, 0.5);
             }
-  
-            // 平滑滚动
-            scroll-behavior: smooth;
-  
-            .option-item {
+          }
+
+          // 平滑滚动
+          scroll-behavior: smooth;
+
+          .option-item {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            padding: 10px 20px;
+            background: #ffffff;
+            border: 1.5px solid #303133;
+            border-radius: 24px;
+            cursor: pointer;
+            transition: all 0.3s ease;
+            white-space: nowrap;
+            flex-shrink: 0;
+            user-select: none;
+
+            .option-icon {
+              width: 28px;
+              height: 28px;
+              border-radius: 50%;
               display: flex;
               align-items: center;
-              gap: 8px;
-              padding: 10px 20px;
-              background: #ffffff;
-              border: 1.5px solid #303133;
-              border-radius: 24px;
-              cursor: pointer;
-              transition: all 0.3s ease;
-              white-space: nowrap;
+              justify-content: center;
+              color: #ffffff;
               flex-shrink: 0;
-              user-select: none;
-  
-              .option-icon {
-                width: 28px;
-                height: 28px;
+              transition: all 0.3s ease;
+
+              .option-icon-img {
+                width: 30px;
+                height: 30px;
                 border-radius: 50%;
-                background: #303133;
-                display: flex;
-                align-items: center;
-                justify-content: center;
+              }
+            }
+
+            .option-text {
+              color: #303133;
+              font-size: 14px;
+              font-weight: 500;
+              letter-spacing: 0.3px;
+              transition: color 0.3s ease;
+            }
+
+            &:hover {
+              transform: translateY(-2px);
+              box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+              border-color: #606266;
+            }
+
+            // 激活状态:蓝色底色,白字
+            &.active {
+              background: #409eff;
+              border-color: #409eff;
+              box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
+              transform: translateY(-2px);
+
+              .option-icon {
+                background: rgba(255, 255, 255, 0.2);
                 color: #ffffff;
-                flex-shrink: 0;
-                transition: all 0.3s ease;
-  
-                svg {
-                  width: 16px;
-                  height: 16px;
-                }
               }
-  
+
               .option-text {
-                color: #303133;
-                font-size: 14px;
-                font-weight: 500;
-                letter-spacing: 0.3px;
-                transition: color 0.3s ease;
+                color: #ffffff;
               }
-  
+
               &:hover {
-                transform: translateY(-2px);
-                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-                border-color: #606266;
-              }
-  
-              // 激活状态:蓝色底色,白字
-              &.active {
-                background: #409eff;
-                border-color: #409eff;
-                box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
-                transform: translateY(-2px);
-  
-                .option-icon {
-                  background: rgba(255, 255, 255, 0.2);
-                  color: #ffffff;
-                }
-  
-                .option-text {
-                  color: #ffffff;
-                }
-  
-                &:hover {
-                  background: #66b1ff;
-                  border-color: #66b1ff;
-                  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
-                }
-              }
-  
-              &:active {
-                transform: translateY(0);
+                background: #66b1ff;
+                border-color: #66b1ff;
+                box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
               }
             }
+
+            &:active {
+              transform: translateY(0);
+            }
           }
         }
       }
     }
-  
-    // 移动端样式
-    @media (max-width: 768px) {
-      .home-goods {
-        width: 100%;
-        padding: 0 16px;
-        margin-top: 16px;
-  
-        .goods-list {
-          .goods-list-option {
-            margin-bottom: 16px;
-  
-            .option-scroll-wrapper {
-              gap: 8px;
-              padding: 2px 0 8px 0;
-  
-              .option-item {
-                padding: 6px 14px;
-                border-radius: 18px;
-                border-width: 1px;
-                gap: 6px;
-  
-                .option-icon {
-                  width: 22px;
-                  height: 22px;
-  
-                  svg {
-                    width: 12px;
-                    height: 12px;
-                  }
-                }
-  
-                .option-text {
-                  font-size: 12px;
-                  letter-spacing: 0.2px;
-                }
-  
-                &:hover {
-                  transform: translateY(-1px);
-                }
-  
-                &.active {
-                  transform: translateY(-1px);
+  }
+
+  // 移动端样式
+  @media (max-width: 768px) {
+    .home-goods {
+      width: 100%;
+      padding: 0 16px;
+      margin-top: 16px;
+
+      .goods-list {
+        .goods-list-option {
+          margin-bottom: 16px;
+
+          .option-scroll-wrapper {
+            gap: 8px;
+            padding: 2px 0 8px 0;
+
+            .option-item {
+              padding: 6px 14px;
+              border-radius: 18px;
+              border-width: 1px;
+              gap: 6px;
+
+              .option-icon {
+                width: 22px;
+                height: 22px;
+
+                svg {
+                  width: 12px;
+                  height: 12px;
                 }
               }
+
+              .option-text {
+                font-size: 12px;
+                letter-spacing: 0.2px;
+              }
+
+              &:hover {
+                transform: translateY(-1px);
+              }
+
+              &.active {
+                transform: translateY(-1px);
+              }
             }
           }
         }
       }
     }
   }
-  </style>
-  
+}
+</style>