“陈博论” 9 сар өмнө
parent
commit
8348b1c8df

+ 78 - 52
web/src/api/frontend/me.ts

@@ -2,123 +2,149 @@ import createAxios from '/@/utils/axios'
 
 //我的主页数据
 export function dashboard() {
-    return createAxios({
-        url: '/api/user/dashboard',
-        method: 'get',
-    })
+  return createAxios({
+    url: '/api/user/dashboard',
+    method: 'get',
+  })
 }
 
 //用户购买记录
-export function purchaseRecordsSimple(data:anyObj) {
+export function purchaseRecordsSimple(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.order/purchaseRecordsSimple',
-      method: 'get',
-      params:data
+    url: '/api/invitation.order/purchaseRecordsSimple',
+    method: 'get',
+    params: data
   })
 }
 
 //获取订单详情
-export function orderDetail(data:anyObj) {
+export function orderDetail(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.order/orderDetail',
-      method: 'get',
-      params:data,
+    url: '/api/invitation.order/orderDetail',
+    method: 'get',
+    params: data,
   })
 }
 
 //产品列表
 export function purchasedList() {
   return createAxios({
-      url: '/api/invitation.product/purchasedList',
-      method: 'get',
+    url: '/api/invitation.product/purchasedList',
+    method: 'get',
   })
 }
 
 //产品详情
-export function productDetail(data:anyObj) {
+export function productDetail(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.product/detail',
-      method: 'get',
-      params:data,
+    url: '/api/invitation.product/detail',
+    method: 'get',
+    params: data,
   })
 }
 
 //获取下级用户列表
-export function subordinateList(data:anyObj) {
+export function subordinateList(data: anyObj) {
   return createAxios({
-      url: '/api/user/subordinateList',
-      method: 'get',
-      params:data,
+    url: '/api/user/subordinateList',
+    method: 'get',
+    params: data,
   })
 }
 
 //检查当前用户是否已有企业
 export function checkCompany() {
   return createAxios({
-      url: '/api/invitation.Company/checkCompany',
-      method: 'get',
+    url: '/api/invitation.Company/checkCompany',
+    method: 'get',
   })
 }
 
 //获取我的企业列表
-export function myCompanies(data:anyObj) {
+export function myCompanies(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/myCompanies',
-      method: 'get',
-      params:data,
+    url: '/api/invitation.Company/myCompanies',
+    method: 'get',
+    params: data,
   })
 }
 
 //获取企业成员列表
-export function members(data:anyObj) {
+export function members(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/members',
-      method: 'get',
-      params:data,
+    url: '/api/invitation.Company/members',
+    method: 'get',
+    params: data,
   })
 }
 
 //创建企业
-export function createCompany(data:anyObj) {
+export function createCompany(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/createCompany',
-      method: 'post',
-      data:data,
+    url: '/api/invitation.Company/createCompany',
+    method: 'post',
+    data: data,
   })
 }
 
 //搜索邮箱用户(用于添加成员时搜索)
-export function searchEmail(data:anyObj) {
+export function searchEmail(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/searchEmail',
-      method: 'get',
-      params:data,
+    url: '/api/invitation.Company/searchEmail',
+    method: 'get',
+    params: data,
   })
 }
 
 //通过邮箱添加成员
-export function addMember(data:anyObj) {
+export function addMember(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/addMember',
-      method: 'post',
-      data:data,
+    url: '/api/invitation.Company/addMember',
+    method: 'post',
+    data: data,
   })
 }
 
 //移除企业成员
-export function removeMember(data:anyObj) {
+export function removeMember(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/removeMember',
-      method: 'post',
-      data:data,
+    url: '/api/invitation.Company/removeMember',
+    method: 'post',
+    data: data,
   })
 }
 
 //更新企业信息
-export function updateCompany(data:anyObj) {
+export function updateCompany(data: anyObj) {
+  return createAxios({
+    url: '/api/invitation.Company/updateCompany',
+    method: 'post',
+    data: data,
+  })
+}
+
+//获取可分配的企业套餐列表和企业成员
+export function allocationList() {
+  return createAxios({
+    url: '/api/invitation.Allocation/allocationList',
+    method: 'get',
+  })
+}
+
+//分配套餐给企业成员
+export function assign(data: anyObj) {
+  return createAxios({
+    url: '/api/invitation.Allocation/assign',
+    method: 'post',
+    data: data
+  })
+}
+
+//取消分配
+export function cancel(data: anyObj) {
   return createAxios({
-      url: '/api/invitation.Company/updateCompany',
-      method: 'post',
-      data:data,
+    url: '/api/invitation.Allocation/cancel',
+    method: 'post',
+    data: data
   })
 }

+ 10 - 5
web/src/api/frontend/order.ts

@@ -26,11 +26,7 @@ export function createQrCode(data:anyObj) {
   })
 }
 
-// 查询订单支付状态
-// {
-// 	"order_id": "13" ,
-// "order_no": "20231201001"
-// }
+// 查询订单详情
 export function checkOrderStatus(data:anyObj) {
   return createAxios({
       url: '/api/invitation.order/orderDetail',
@@ -39,3 +35,12 @@ export function checkOrderStatus(data:anyObj) {
   })
 }
 
+// 查询订单详情
+export function check(data:anyObj) {
+  return createAxios({
+      url: '/api/product.OrderStatus/check',
+      method: 'get',
+      params:data,
+  })
+}
+

+ 8 - 10
web/src/views/frontend/Order.vue

@@ -143,7 +143,7 @@ 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'
+import { createOrder, createQrCode, check } from '/@/api/frontend/order'
 import { checkCompany } from '/@/api/frontend/me'
 
 defineOptions({ name: 'ClientOrder' })
@@ -339,13 +339,12 @@ const startPayStatusPolling = (orderId, orderNo) => {
         order_id: orderId,
         order_no: orderNo
       }
-      const res = await checkOrderStatus(checkData)
+      const res = await check(checkData)
 
-      // 支付状态:1-已支付,0-待支付,2-支付失败,3-已取消(兼容数字/字符串)
-      const rawStatus = res.data?.order?.pay_status
-      const status = rawStatus != null ? String(rawStatus) : ''
+      //支付状态:0待支付,1已支付,2支付失败
+      const rawStatus = res.data?.pay_status
 
-      if (status === '1') {
+      if (rawStatus == '1') {
         // 支付成功
         payStatus.value = 'success'
         stopPayStatusPolling()
@@ -355,7 +354,7 @@ const startPayStatusPolling = (orderId, orderNo) => {
           currentOrderInfo.value = {
             ...currentOrderInfo.value,
             ...res.data,
-            pay_status: status
+            pay_status: rawStatus
           }
         }
 
@@ -363,7 +362,7 @@ const startPayStatusPolling = (orderId, orderNo) => {
         setTimeout(() => {
           handlePaySuccess()
         }, 3000)
-      } else if (status === '2' || status === '3') {
+      } else if (rawStatus == '2' || rawStatus == '3') {
         // 支付失败或已取消
         payStatus.value = 'failed'
         stopPayStatusPolling()
@@ -372,7 +371,6 @@ const startPayStatusPolling = (orderId, orderNo) => {
       // status === 0 或其他值表示待支付,继续轮询
       // status === 0 或其他值表示待支付,继续轮询
     } catch (error) {
-      console.error('查询支付状态失败:', error)
       // 查询失败不中断轮询,继续尝试(除非超过最大次数)
       if (payCheckCount.value > MAX_PAY_CHECK_COUNT) {
         stopPayStatusPolling()
@@ -444,7 +442,7 @@ const handlePaySuccess = () => {
   ElMessage.success('支付成功!')
 
   // 使用订单详情接口需要的参数跳转到成功页
-  const orderId = currentOrderInfo.value.order_id || currentOrderInfo.value.id
+  const orderId = currentOrderInfo.value.order_id
   const orderNo = currentOrderInfo.value.order_no
 
   if (orderId || orderNo) {

+ 38 - 23
web/src/views/frontend/Reset.vue

@@ -17,9 +17,9 @@
           <el-input v-model.trim="resetForm.email" placeholder="请输入注册邮箱" clearable />
         </el-form-item>
 
-        <el-form-item label="邮箱验证码" prop="emailCode">
+        <el-form-item label="邮箱验证码" prop="captcha">
           <div class="client-reset__code-row">
-            <el-input class="client-reset__code-input" v-model.trim="resetForm.emailCode" placeholder="请输入6位验证码"
+            <el-input class="client-reset__code-input" v-model.trim="resetForm.captcha" placeholder="请输入6位验证码"
               maxlength="6" />
             <el-button class="client-reset__code-btn" type="primary" :loading="sendingCode" :disabled="codeDisabled"
               @click="handleSendEmailCode">
@@ -53,6 +53,10 @@
 import { computed, onBeforeUnmount, reactive, ref } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
+import { retrievePassword } from '/@/api/frontend/user/index'
+import { sendEms } from '/@/api/common'
+import clickCaptcha from '/@/components/clickCaptcha'
+import { uuid } from '/@/utils/random'
 
 defineOptions({ name: 'ClientReset' })
 
@@ -61,9 +65,10 @@ const router = useRouter()
 const resetFormRef = ref(null)
 const resetForm = reactive({
   email: '',
-  emailCode: '',
+  captcha: '',
   password: '',
-  confirmPassword: ''
+  confirmPassword: '',
+  captchaId: uuid()
 })
 
 const validateConfirmPassword = (rule, value, callback) => {
@@ -81,7 +86,7 @@ const resetRules = reactive({
     { required: true, message: '请输入注册邮箱', trigger: 'blur' },
     { type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
   ],
-  emailCode: [
+  captcha: [
     { required: true, message: '请输入验证码', trigger: 'blur' },
     { min: 6, max: 6, message: '请输入6位验证码', trigger: 'blur' }
   ],
@@ -129,8 +134,12 @@ const handleReset = () => {
     if (!valid) return
     submitting.value = true
     try {
-      // TODO: 接入后端重置密码接口
-      await new Promise(resolve => setTimeout(resolve, 600))
+      await retrievePassword({
+        type: 'email',
+        account: resetForm.email.trim(),
+        captcha: resetForm.captcha,
+        password: resetForm.password
+      })
       ElMessage.success('密码已重置,请使用新密码登录')
       router.push({ path: '/user/login', query: { email: resetForm.email.trim() } })
     } catch (error) {
@@ -149,25 +158,31 @@ const handleSendEmailCode = () => {
     return
   }
 
-  resetFormRef.value.validateField('email').then(async (valid) => {
-    if (!valid) {
-      // 验证失败,Element Plus 会自动显示错误信息
-      return
-    }
-    sendingCode.value = true
-    try {
-      // TODO: 接入后端发送重置验证码接口
-      await new Promise(resolve => setTimeout(resolve, 500))
-      ElMessage.success('验证码发送成功,请查收邮箱')
-      startCountdown(60)
-    } catch (err) {
-      ElMessage.error(err?.message || '验证码发送失败')
-    } finally {
-      sendingCode.value = false
-    }
+  resetFormRef.value.validateField('email').then((valid) => {
+    if (!valid) return
+    // 先进行点击验证码验证
+    clickCaptcha(resetForm.captchaId, (captchaInfo) => {
+      sendEmailCaptcha(captchaInfo)
+    })
   })
 }
 
+const sendEmailCaptcha = (captchaInfo) => {
+  sendingCode.value = true
+  sendEms(resetForm.email.trim(), 'user_retrieve_pwd', {
+    captchaId: resetForm.captchaId,
+    captchaInfo: captchaInfo
+  })
+    .then((res) => {
+      if (res.code == 1) {
+        startCountdown(60)
+      }
+    })
+    .finally(() => {
+      sendingCode.value = false
+    })
+}
+
 onBeforeUnmount(() => {
   clearCountdown()
 })

+ 13 - 2
web/src/views/frontend/components/Me/info.vue

@@ -29,7 +29,7 @@
             <div class="commission-desc">累计获得的所有佣金</div>
           </div>
         </div>
-        <div class="commission-card">
+        <div class="commission-card" @click="gotoWithdraw">
           <div class="commission-icon icon-current">
             <el-icon><Wallet /></el-icon>
           </div>
@@ -206,15 +206,18 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref,} from 'vue'
 import { Avatar, Coin, Medal, Wallet, Money, Lock, TrendCharts, Calendar, DocumentCopy, User, Link, Ticket, Clock, Monitor, Connection } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import { storeToRefs } from 'pinia'
 import { useUserInfo } from '/@/stores/userInfo'
 import { dashboard } from '/@/api/frontend/me'
+import { useRouter } from 'vue-router'
 
 defineOptions({ name: 'MeInfo' })
 
+const router = useRouter()
+
 const userStore = useUserInfo()
 const { username, email, id } = storeToRefs(userStore)
 
@@ -278,6 +281,14 @@ const getUserInfo = () => {
   })
 }
 
+//跳转提现页面
+const gotoWithdraw =()=>{
+  router.push({
+    path: '/user/me',
+    query: { tab: 'withdraw-list' }
+  })
+}
+
 onMounted(() => {
   getUserInfo()
 })

+ 20 - 24
web/src/views/frontend/components/Me/purchaseHistory.vue

@@ -12,8 +12,9 @@
         <template #prepend>订单号</template>
       </el-input>
 
-      <el-date-picker v-model="searchForm.dateRange" class="filter-item date-picker" style="width: 100px !important;" type="daterange" unlink-panels
-        start-placeholder="下单开始时间" end-placeholder="下单结束时间" range-separator="~" value-format="YYYY-MM-DD" clearable />
+      <el-date-picker v-model="searchForm.dateRange" class="filter-item date-picker" style="width: 100px !important;"
+        type="daterange" unlink-panels start-placeholder="下单开始时间" end-placeholder="下单结束时间" range-separator="~"
+        value-format="YYYY-MM-DD" clearable />
 
       <el-button size="small" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
       <el-button size="small" @click="handleReset">重置</el-button>
@@ -166,16 +167,11 @@ const loadPurchaseRecords = async () => {
   try {
     loading.value = true
     const res = await purchaseRecordsSimple({})
-    if (res.code === 1 && res.data) {
-      const data = res.data as PurchaseRecordsResponse
-      orders.value = data.list || []
-      stats.value = data.stats || { total_orders: 0, paid_orders: 0, unpaid_orders: 0 }
-      pagination.total = data.total || 0
-    } else {
-      ElMessage.error(res.msg || '获取购买记录失败')
-    }
+    const data = res.data as PurchaseRecordsResponse
+    orders.value = data.list || []
+    stats.value = data.stats || { total_orders: 0, paid_orders: 0, unpaid_orders: 0 }
+    pagination.total = data.total || 0
   } catch (error) {
-    console.error('加载购买记录失败:', error)
     ElMessage.error('加载购买记录失败,请稍后重试')
   } finally {
     loading.value = false
@@ -193,7 +189,7 @@ const baseFilteredOrders = computed(() => {
   }
 
   // 日期范围过滤
-  if (appliedFilters.dateRange && appliedFilters.dateRange.length === 2) {
+  if (appliedFilters.dateRange && appliedFilters.dateRange.length == 2) {
     filtered = filtered.filter(order => {
       return isWithinRange(order.order_time_formatted, appliedFilters.dateRange)
     })
@@ -207,11 +203,11 @@ const filteredOrders = computed(() => {
   let filtered = baseFilteredOrders.value
 
   // 状态过滤
-  if (activeStatus.value !== 'all') {
-    if (activeStatus.value === 'paid') {
-      filtered = filtered.filter(order => order.pay_status_text === '已支付')
-    } else if (activeStatus.value === 'unpaid') {
-      filtered = filtered.filter(order => order.pay_status_text === '未支付')
+  if (activeStatus.value != 'all') {
+    if (activeStatus.value == 'paid') {
+      filtered = filtered.filter(order => order.pay_status_text == '已支付')
+    } else if (activeStatus.value == 'unpaid') {
+      filtered = filtered.filter(order => order.pay_status_text == '未支付')
     }
   }
 
@@ -223,8 +219,8 @@ const filteredStats = computed(() => {
   const base = baseFilteredOrders.value
   return {
     total_orders: base.length,
-    paid_orders: base.filter(order => order.pay_status_text === '已支付').length,
-    unpaid_orders: base.filter(order => order.pay_status_text === '未支付').length
+    paid_orders: base.filter(order => order.pay_status_text == '已支付').length,
+    unpaid_orders: base.filter(order => order.pay_status_text == '未支付').length
   }
 })
 
@@ -232,7 +228,7 @@ const handleSearch = () => {
   // 更新应用的筛选条件
   appliedFilters.orderId = searchForm.orderId || ''
   // 确保日期范围格式正确
-  if (searchForm.dateRange && Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
+  if (searchForm.dateRange && Array.isArray(searchForm.dateRange) && searchForm.dateRange.length == 2) {
     appliedFilters.dateRange = [...searchForm.dateRange]
   } else {
     appliedFilters.dateRange = []
@@ -254,14 +250,14 @@ const handleTabClick = (tab: { paneName?: string | number; name?: string | numbe
 
 const getOrderCount = (status: OrderTabStatus) => {
   // 使用筛选后的统计结果
-  if (status === 'all') return filteredStats.value.total_orders || 0
-  if (status === 'paid') return filteredStats.value.paid_orders || 0
-  if (status === 'unpaid') return filteredStats.value.unpaid_orders || 0
+  if (status == 'all') return filteredStats.value.total_orders || 0
+  if (status == 'paid') return filteredStats.value.paid_orders || 0
+  if (status == 'unpaid') return filteredStats.value.unpaid_orders || 0
   return 0
 }
 
 const isWithinRange = (orderTime: string, range: string[]) => {
-  if (!range || range.length !== 2 || !range[0] || !range[1]) return true
+  if (!range || range.length != 2 || !range[0] || !range[1]) return true
   const [start, end] = range
 
   // 确保日期格式正确,处理 YYYY-MM-DD 格式

+ 400 - 86
web/src/views/frontend/components/Me/purchaseProduct.vue

@@ -1,28 +1,95 @@
 <template>
   <div class="me-purchase-product">
     <div class="product-header">
-      <h2 class="product-title">我的产品</h2>
+      <h2 class="product-title">我的产品({{ productsNum }})</h2>
     </div>
     <div class="product-content">
       <el-empty v-if="products.length == 0" description="暂无产品">
         <el-button type="primary" @click="gotoBuy">去购买</el-button>
       </el-empty>
       <div v-else class="product-grid">
-        <div v-for="item in products" :key="item.id" class="product-card" @click="handleProductClick(item.id)">
+        <div v-for="item in products" :key="item.usage_id" class="product-card" :class="{ 'expired': item.status != 1 }"
+          @click="handleProductClick(item)">
+          <!-- 状态标签 -->
+          <div class="status-badge" :class="getStatusClass(item.status)">
+            {{ getStatusText(item.status) }}
+          </div>
+
+          <!-- 产品图片 -->
           <div class="product-image-wrapper">
-            <img :src="item.image" alt="">
+            <img :src="item.image" :alt="item.product_name">
+            <div class="product-type-badge" :class="item.product_type === 1 ? 'web' : 'software'">
+              <el-icon v-if="item.product_type == 1">
+                <Link />
+              </el-icon>
+              <el-icon v-else>
+                <Monitor />
+              </el-icon>
+              <span>{{ item.product_type == 1 ? '网页' : '软件' }}</span>
+            </div>
           </div>
+
+          <!-- 产品信息 -->
           <div class="product-info">
-            <div class="product-name">{{ item.name }}</div>
-            <!-- <div class="product-desc">{{ item.description }}</div>
-            <div v-if="item.expireAt" class="product-expire">
-              <span class="expire-label">有效期到:</span>
-              <span class="expire-text">{{ item.expireAt }}</span>
+            <div class="product-name" :title="item.product_name">{{ item.product_name }}</div>
+
+            <!-- 套餐信息 -->
+            <div class="package-info">
+              <el-tag size="small" type="primary" effect="plain">{{ item.package_type_text }}</el-tag>
+              <el-tag size="small" type="warning" effect="plain">{{ item.card_type_text }}</el-tag>
+            </div>
+
+            <!-- 使用情况 -->
+            <div class="usage-info">
+              <!-- 次卡显示剩余次数 -->
+              <div v-if="item.card_type == 1" class="usage-item">
+                <el-icon>
+                  <Tickets />
+                </el-icon>
+                <span>剩余次数: <strong>{{ item.remaining_times }}</strong></span>
+              </div>
+
+              <!-- 积分卡显示剩余积分 -->
+              <div v-if="item.card_type == 2" class="usage-item">
+                <el-icon>
+                  <Coin />
+                </el-icon>
+                <span>剩余积分: <strong>{{ item.remaining_points }}</strong></span>
+              </div>
+
+              <!-- 日期卡显示到期时间 -->
+              <div v-if="[3, 4, 5, 6, 7].includes(item.card_type)" class="usage-item">
+                <el-icon>
+                  <Calendar />
+                </el-icon>
+                <span>{{ item.display_info }}</span>
+              </div>
+            </div>
+
+            <!-- 席位信息(企业套餐) -->
+            <div v-if="item.package_type == 2 && item.total_seats" class="seats-info">
+              <el-progress :percentage="getSeatPercentage(item)" :color="getSeatColor(item)" :stroke-width="6" />
+              <div class="seats-text">
+                <span>已分配: {{ item.assigned_seats || 0 }}/{{ item.total_seats }}</span>
+                <span class="available">可用: {{ item.available_seats || 0 }}</span>
+              </div>
+            </div>
+
+            <!-- 底部信息 -->
+            <div class="product-footer">
+              <div class="buy-info">
+                <el-icon>
+                  <ShoppingCart />
+                </el-icon>
+                <span>{{ formatDate(item.buy_time) }}</span>
+              </div>
+              <div v-if="item.is_allocated == 1" class="allocated-badge">
+                <el-icon>
+                  <Share />
+                </el-icon>
+                <span>已分配</span>
+              </div>
             </div>
-            <div class="product-meta">
-              <span class="product-type">{{ item.typeLabel }}</span>
-              <span v-if="item.tag" class="product-tag">{{ item.tag }}</span>
-            </div> -->
           </div>
         </div>
       </div>
@@ -32,59 +99,165 @@
 
 <script setup lang="ts">
 import { onMounted, ref } from 'vue'
-import { purchasedList, productDetail } from '/@/api/frontend/me'
+import { purchasedList } from '/@/api/frontend/me'
 import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import {
+  Link,
+  Monitor,
+  Tickets,
+  Coin,
+  Calendar,
+  ShoppingCart,
+  Share
+} from '@element-plus/icons-vue'
+
 defineOptions({ name: 'MePurchaseProduct' })
 
 const router = useRouter()
 
-type ProductType = 1 | 2 //产品类型(1:网页2:软件)
-type Product = {
-  id: string
-  name: string
-  description: string
-  expireAt?: string
-  type: ProductType
-  typeLabel: string
-  link: string
-  tag?: string
+type ProductItem = {
+  product_id: number
+  product_name: string
+  product_type: 1 | 2 // 1=网页,2=软件
+  product_link: string
   image: string
+  package_spec: string
+  package_type: 1 | 2 // 1=个人套餐,2=企业套餐
+  package_type_text: string
+  card_type: 1 | 2 | 3 | 4 | 5 | 6 | 7 // 1=次卡,2=积分卡,3=日卡,4=周卡,5=季卡,6=月卡,7=年卡
+  card_type_text: string
+  total_quantity: number
+  effective_end_time: string | null
+  remaining_times: number
+  remaining_points: number
+  display_info: string
+  usage_id: number
+  start_time: string
+  last_use_time: string | null
+  status: 1 | 2 | 3 // 1=有效,2=已过期,3=已用完
+  buy_time: string
+  is_allocated: 0 | 1 // 0=未分配,1=已分配
+  company_id: number | null
+  allocation_id: number | null
+  total_seats: number | null
+  assigned_seats: number | null
+  available_seats: number | null
 }
 
-// 产品列表
-const products = ref<Product[]>([])
+type CompanyInfo = {
+  company_id: number
+  company_name: string
+  is_creator: boolean
+}
 
+// 产品列表
+const products = ref<ProductItem[]>([])
+// 企业信息
+const companyInfo = ref<CompanyInfo | null>(null)
+// 产品数量
+const productsNum = ref()
 
 // 获取列表
 const getList = async () => {
-  const res = await purchasedList()
-  products.value = res.data as Product[]
+  try {
+    const res = await purchasedList()
+    products.value = res.data.products || []
+    companyInfo.value = res.data.company_info || null
+    productsNum.value = res.data.total
+  } catch (error) {
+    ElMessage.error('获取产品列表失败')
+  }
 }
 
-//跳转购买
-const gotoBuy = () =>{
+// 跳转购买
+const gotoBuy = () => {
   router.push('/')
 }
 
-// 点击跳转
-const handleProductClick = (id: string) => {
-  productDetail({ id }).then(res => {
-    const data = res.data.product
-    if (!data?.link) return
-    if (data.type == 2) {
-      const a = document.createElement('a')
-      a.href = data.link
-      a.target = '_blank'
-      a.rel = 'noopener noreferrer'
-      a.download = ''
-      document.body.appendChild(a)
-      a.click()
-      document.body.removeChild(a)
-      return
-    }
+// 获取状态文本
+const getStatusText = (status: number) => {
+  const statusMap: Record<number, string> = {
+    1: '有效',
+    2: '已过期',
+    3: '已用完'
+  }
+  return statusMap[status] || '未知'
+}
+
+// 获取状态样式类
+const getStatusClass = (status: number) => {
+  const classMap: Record<number, string> = {
+    1: 'status-active',
+    2: 'status-expired',
+    3: 'status-used-up'
+  }
+  return classMap[status] || ''
+}
+
+// 计算席位使用百分比
+const getSeatPercentage = (item: ProductItem) => {
+  if (!item.total_seats || item.total_seats === 0) return 0
+  return Math.round(((item.assigned_seats || 0) / item.total_seats) * 100)
+}
+
+// 获取席位进度条颜色
+const getSeatColor = (item: ProductItem) => {
+  const percentage = getSeatPercentage(item)
+  if (percentage >= 90) return '#f56c6c'
+  if (percentage >= 70) return '#e6a23c'
+  return '#67c23a'
+}
+
+// 格式化日期
+const formatDate = (dateStr: string) => {
+  if (!dateStr) return ''
+  const date = new Date(dateStr)
+  const now = new Date()
+  const diff = now.getTime() - date.getTime()
+  const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+
+  if (days == 0) return '今天'
+  if (days == 1) return '昨天'
+  if (days < 7) return `${days}天前`
+  if (days < 30) return `${Math.floor(days / 7)}周前`
+  if (days < 365) return `${Math.floor(days / 30)}个月前`
+
+  return dateStr.split(' ')[0]
+}
+
+// 点击产品卡片
+const handleProductClick = (item: ProductItem) => {
+  // 如果已过期或已用完,提示用户
+  if (item.status == 2) {
+    ElMessage.warning('该产品已过期')
+    return
+  }
+  if (item.status == 3) {
+    ElMessage.warning('该产品已用完')
+    return
+  }
+
+  if (!item.product_link) {
+    ElMessage.warning('产品链接不存在')
+    return
+  }
+
+  // 软件类型,触发下载
+  if (item.product_type == 2) {
+    const a = document.createElement('a')
+    a.href = item.product_link
+    a.target = '_blank'
+    a.rel = 'noopener noreferrer'
+    a.download = ''
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    return
+  }
 
-    window.open(data.link, '_blank', 'noopener,noreferrer')
-  })
+  // 网页类型,打开新窗口
+  window.open(item.product_link, '_blank', 'noopener,noreferrer')
 }
 
 onMounted(() => {
@@ -97,108 +270,249 @@ onMounted(() => {
   width: 100%;
 
   .product-header {
-    margin-bottom: 20px;
-    padding-bottom: 15px;
-    border-bottom: 1px solid #e4e7ed;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 24px;
+    padding-bottom: 16px;
+    border-bottom: 2px solid #e4e7ed;
 
     .product-title {
       margin: 0;
-      font-size: 20px;
+      font-size: 24px;
       font-weight: bold;
       color: #303133;
     }
+
+    .company-info {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 16px;
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      border-radius: 20px;
+      color: #fff;
+      font-size: 14px;
+
+      .el-icon {
+        font-size: 16px;
+      }
+    }
   }
 
   .product-content {
     padding: 20px 0;
-    color: #606266;
 
     .product-grid {
       display: grid;
-      grid-template-columns: repeat(auto-fill, minmax(220px, 260px));
+      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
       gap: 24px;
     }
 
     .product-card {
+      position: relative;
       cursor: pointer;
       background: #fff;
-      border-radius: 10px;
+      border-radius: 12px;
       border: 1px solid #e4e7ed;
-      transition: all 0.2s ease;
+      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
       overflow: hidden;
 
       &:hover {
         border-color: #409eff;
-        box-shadow: 0 6px 16px rgba(64, 158, 255, 0.15);
-        transform: translateY(-2px);
+        box-shadow: 0 8px 24px rgba(64, 158, 255, 0.2);
+        transform: translateY(-4px);
+      }
+
+      &.expired {
+        opacity: 0.7;
+
+        &:hover {
+          border-color: #909399;
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+        }
+      }
+    }
+
+    .status-badge {
+      position: absolute;
+      top: 12px;
+      right: 12px;
+      padding: 4px 12px;
+      border-radius: 12px;
+      font-size: 12px;
+      font-weight: 600;
+      z-index: 2;
+      backdrop-filter: blur(8px);
+
+      &.status-active {
+        background: rgba(103, 194, 58, 0.9);
+        color: #fff;
+      }
+
+      &.status-expired {
+        background: rgba(245, 108, 108, 0.9);
+        color: #fff;
+      }
+
+      &.status-used-up {
+        background: rgba(144, 147, 153, 0.9);
+        color: #fff;
       }
     }
 
     .product-image-wrapper {
-      padding: 14px 14px 0;
+      position: relative;
+      padding: 16px 16px 0;
 
       img {
         display: block;
         width: 100%;
-        height: 160px;
-        border-radius: 6px;
+        height: 180px;
+        border-radius: 8px;
         object-fit: cover;
       }
+
+      .product-type-badge {
+        position: absolute;
+        bottom: 8px;
+        left: 24px;
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        padding: 6px 12px;
+        border-radius: 16px;
+        font-size: 12px;
+        font-weight: 600;
+        backdrop-filter: blur(8px);
+
+        &.web {
+          background: rgba(64, 158, 255, 0.9);
+          color: #fff;
+        }
+
+        &.software {
+          background: rgba(103, 194, 58, 0.9);
+          color: #fff;
+        }
+
+        .el-icon {
+          font-size: 14px;
+        }
+      }
     }
 
     .product-info {
-      padding: 12px 16px 16px;
+      padding: 16px;
       display: flex;
       flex-direction: column;
-      gap: 6px;
+      gap: 12px;
     }
 
     .product-name {
       font-size: 16px;
       font-weight: 600;
       color: #303133;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
 
-    .product-desc {
+    .package-info {
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+
+    .package-spec {
       font-size: 13px;
-      color: #909399;
+      color: #606266;
       line-height: 1.5;
-      min-height: 38px;
+      padding: 8px 12px;
+      background: #f5f7fa;
+      border-radius: 6px;
+      border-left: 3px solid #409eff;
     }
 
-    .product-expire {
-      margin-top: 2px;
-      font-size: 12px;
+    .usage-info {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
 
-      .expire-label {
-        color: #909399;
+      .usage-item {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        font-size: 13px;
+        color: #606266;
+        padding: 6px 10px;
+        background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
+        border-radius: 6px;
+
+        .el-icon {
+          font-size: 16px;
+          color: #409eff;
+        }
+
+        strong {
+          color: #409eff;
+          font-weight: 600;
+        }
       }
+    }
 
-      .expire-text {
+    .seats-info {
+      padding: 10px 12px;
+      background: #f5f7fa;
+      border-radius: 8px;
+
+      .seats-text {
+        display: flex;
+        justify-content: space-between;
+        margin-top: 8px;
+        font-size: 12px;
         color: #606266;
+
+        .available {
+          color: #67c23a;
+          font-weight: 600;
+        }
       }
     }
 
-    .product-meta {
+    .product-footer {
       display: flex;
       justify-content: space-between;
       align-items: center;
-      margin-top: 4px;
-      font-size: 12px;
-    }
+      padding-top: 12px;
+      border-top: 1px solid #e4e7ed;
 
-    .product-type {
-      padding: 3px 8px;
-      border-radius: 10px;
-      background: #ecf5ff;
-      color: #409eff;
-    }
+      .buy-info {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        font-size: 12px;
+        color: #909399;
+
+        .el-icon {
+          font-size: 14px;
+        }
+      }
 
-    .product-tag {
-      padding: 3px 8px;
-      border-radius: 10px;
-      background: #fef0f0;
-      color: #f56c6c;
+      .allocated-badge {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        padding: 4px 10px;
+        background: #e6f7ff;
+        border-radius: 12px;
+        font-size: 12px;
+        color: #1890ff;
+
+        .el-icon {
+          font-size: 14px;
+        }
+      }
     }
   }
 }

+ 121 - 99
web/src/views/frontend/components/ProductSeatDialog.vue

@@ -1,34 +1,21 @@
 <template>
-  <el-dialog
-    title="产品分配席位"
-    :model-value="props.visible"
-    @update:model-value="(val) => emit('update:visible', val)"
-    width="900px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="true"
-    class="product-seat-dialog"
-    @close="handleClose"
-  >
+  <el-dialog title="产品分配席位" :model-value="props.visible" @update:model-value="(val) => emit('update:visible', val)"
+    width="900px" :close-on-click-modal="false" :close-on-press-escape="true" class="product-seat-dialog"
+    @close="handleClose">
 
     <div class="dialog-content">
       <!-- 操作模式选择 - 使用标签页样式 -->
       <div class="mode-section">
         <div class="mode-tabs">
-          <div
-            class="mode-tab"
-            :class="{ active: operationMode === 'assign' }"
-            @click="operationMode = 'assign'; handleModeChange()"
-          >
+          <div class="mode-tab" :class="{ active: operationMode == 'assign' }"
+            @click="operationMode = 'assign'; handleModeChange()">
             <el-icon>
               <Plus />
             </el-icon>
             <span>分配席位</span>
           </div>
-          <div
-            class="mode-tab"
-            :class="{ active: operationMode === 'cancel' }"
-            @click="operationMode = 'cancel'; handleModeChange()"
-          >
+          <div class="mode-tab" :class="{ active: operationMode == 'cancel' }"
+            @click="operationMode = 'cancel'; handleModeChange()">
             <el-icon>
               <Minus />
             </el-icon>
@@ -48,13 +35,8 @@
           </div>
         </div>
         <div class="product-grid">
-          <div
-            v-for="product in currentProductList"
-            :key="product.id"
-            class="product-card"
-            :class="{ active: selectedProduct === product.id }"
-            @click="handleProductSelect(product.id)"
-          >
+          <div v-for="product in currentProductList" :key="product.id" class="product-card"
+            :class="{ active: selectedProduct === product.id }" @click="handleProductSelect(product.id)">
             <div class="product-image">
               <img :src="getProductImage(product.id)" :alt="product.name">
             </div>
@@ -62,12 +44,21 @@
               <div class="product-name">{{ product.name }}</div>
               <div v-if="product.description" class="product-desc">{{ product.description }}</div>
               <div class="product-meta">
+                <span class="card-type-badge">
+                  <el-icon>
+                    <Ticket />
+                  </el-icon>
+                  <span>{{ product.cardTypeText }}</span>
+                </span>
                 <span class="seats-info">
                   <el-icon>
                     <UserFilled />
                   </el-icon>
-                  <span>
-                    {{ operationMode === 'assign' ? `可用席位: ${product.seats ?? 0}` : `已分配 ${product.assignedCount || 0} 人` }}
+                  <span v-if="operationMode === 'assign'">
+                    可用: {{ product.seats ?? 0 }}/{{ product.totalSeats ?? 0 }}
+                  </span>
+                  <span v-else>
+                    已分配: {{ product.assignedSeats || 0 }}/{{ product.totalSeats ?? 0 }}
                   </span>
                 </span>
               </div>
@@ -115,12 +106,8 @@
           <div class="member-list-scroll">
             <template v-if="selectedProduct">
               <el-checkbox-group v-model="selectedMembers" @change="handleMemberChange">
-                <div
-                  v-for="member in currentMemberList"
-                  :key="member.id"
-                  class="member-card"
-                  :class="{ selected: selectedMembers.includes(member.id) }"
-                >
+                <div v-for="member in currentMemberList" :key="member.id" class="member-card"
+                  :class="{ selected: selectedMembers.includes(member.id) }">
                   <el-checkbox :label="member.id" class="member-checkbox">
                     <div class="member-avatar">
                       <el-icon>
@@ -179,7 +166,8 @@
 
 <script setup>
 import { computed, getCurrentInstance, ref, watch } from 'vue'
-import { Check, Close, Goods, Minus, Plus, Remove, User, UserFilled } from '@element-plus/icons-vue'
+import { Check, Close, Goods, Minus, Plus, Remove, Ticket, User, UserFilled } from '@element-plus/icons-vue'
+import { allocationList, assign, cancel } from '/@/api/frontend/me'
 
 defineOptions({ name: 'ProductSeatDialog' })
 
@@ -198,7 +186,7 @@ const props = defineProps({
   }
 })
 
-const emit = defineEmits(['update:visible', 'success', 'close'])
+const emit = defineEmits(['update:visible', 'success', 'close', 'update:memberList'])
 const { proxy } = getCurrentInstance()
 
 const operationMode = ref('assign')
@@ -216,8 +204,10 @@ const currentProductList = computed(() =>
 const currentMemberList = computed(() => {
   if (!selectedProduct.value) return []
   if (operationMode.value === 'assign') {
+    // 分配模式:显示未分配的成员列表
     return props.memberList
   }
+  // 取消分配模式:显示该套餐已分配的成员列表
   return productMembersMap.value[selectedProduct.value] || []
 })
 
@@ -265,61 +255,82 @@ const initDialog = async () => {
 
 const loadAvailableProducts = async () => {
   try {
-    availableProducts.value = [
-      {
-        id: 1,
-        name: '聚德神笔套餐',
-        description: '聚德 AI 写作/办公套件',
-        seats: 10
-      },
-      {
-        id: 2,
-        name: '棱镜套餐',
-        description: '图像/视频增强助手',
-        seats: 5
+    const res = await allocationList()
+    if (res.data && res.data.packages) {
+      // 筛选出有可用席位的套餐(用于分配模式)
+      availableProducts.value = res.data.packages
+        .filter(pkg => pkg.available_seats > 0)
+        .map(pkg => ({
+          id: pkg.seats_id,
+          name: pkg.product_name,
+          description: `${pkg.spec_name} - ${pkg.card_type_text}`,
+          seats: pkg.available_seats,
+          packagePriceId: pkg.package_price_id,
+          productId: pkg.product_id,
+          cardType: pkg.card_type,
+          cardTypeText: pkg.card_type_text,
+          totalSeats: pkg.total_seats,
+          assignedSeats: pkg.assigned_seats
+        }))
+
+      // 存储未分配成员列表
+      if (res.data.members && res.data.members.length > 0) {
+        // 将未分配成员转换为组件需要的格式
+        const unassignedMembers = res.data.members.map(member => ({
+          id: member.member_id,
+          name: member.member_name,
+          email: member.email,
+          avatar: member.avatar
+        }))
+        // 更新 props.memberList(这里需要通过 emit 通知父组件更新)
+        emit('update:memberList', unassignedMembers)
       }
-    ]
-    autoSelectFirstProduct()
+
+      autoSelectFirstProduct()
+    }
   } catch (error) {
-    console.error('加载产品列表失败:', error)
-    message?.error('加载产品列表失败')
-    availableProducts.value = []
-    selectedProduct.value = null
+    console.error('加载可分配套餐失败:', error)
+    message?.error('加载可分配套餐失败')
   }
 }
 
 const loadAssignedProducts = async () => {
   try {
-    // 加载所有已分配的产品列表
-    assignedProducts.value = [
-      {
-        id: 1,
-        name: '聚德神笔套餐',
-        description: '聚德 AI 写作/办公套件',
-        assignedCount: 2
-      }
-    ]
-
-    // 加载每个产品下的人员列表
-    // 这里应该调用API获取每个产品下的人员
-    // 暂时使用模拟数据
-    assignedProducts.value.forEach(product => {
-      // 模拟数据:每个产品下的人员
-      productMembersMap.value[product.id] = props.memberList.filter((member, index) => {
-        // 模拟:产品1包含前2个成员
-        if (product.id === 1) {
-          return index < 2
+    const res = await allocationList()
+    if (res.data && res.data.packages) {
+      // 筛选出已有分配成员的套餐(用于取消分配模式)
+      assignedProducts.value = res.data.packages
+        .filter(pkg => pkg.allocated_members && pkg.allocated_members.length > 0)
+        .map(pkg => ({
+          id: pkg.seats_id,
+          name: pkg.product_name,
+          description: `${pkg.spec_name} - ${pkg.card_type_text}`,
+          assignedCount: pkg.assigned_seats,
+          packagePriceId: pkg.package_price_id,
+          productId: pkg.product_id,
+          cardType: pkg.card_type,
+          cardTypeText: pkg.card_type_text,
+          totalSeats: pkg.total_seats,
+          assignedSeats: pkg.assigned_seats
+        }))
+
+      // 存储每个套餐的已分配成员列表
+      res.data.packages.forEach(pkg => {
+        if (pkg.allocated_members && pkg.allocated_members.length > 0) {
+          productMembersMap.value[pkg.seats_id] = pkg.allocated_members.map(member => ({
+            id: member.member_id,
+            name: member.member_name,
+            email: member.email,
+            avatar: member.avatar
+          }))
         }
-        return false
       })
-    })
-    autoSelectFirstProduct()
+
+      autoSelectFirstProduct()
+    }
   } catch (error) {
-    console.error('加载已分配产品失败:', error)
-    message?.error('加载已分配产品失败')
-    assignedProducts.value = []
-    productMembersMap.value = {}
-    selectedProduct.value = null
+    console.error('加载已分配套餐失败:', error)
+    message?.error('加载已分配套餐失败')
   }
 }
 
@@ -363,41 +374,35 @@ const handleSelectNone = () => {
 
 const getProductImage = productId => `https://picsum.photos/200/200?random=${productId}`
 
-const handleCancelProduct = product => {
-  selectedProduct.value = product.id
-  handleSubmit()
-}
-
 const handleSubmit = async () => {
   if (!selectedProduct.value) {
-    message?.warning(operationMode.value === 'assign' ? '没有可分配的套餐' : '请先选择要取消的套餐')
+    message?.warning(operationMode.value == 'assign' ? '没有可分配的套餐' : '请先选择要取消的套餐')
     return
   }
 
-  if (selectedMembers.value.length === 0) {
+  if (selectedMembers.value.length == 0) {
     message?.warning('请至少选择一个人员')
     return
   }
 
-  if (operationMode.value !== 'assign' && selectedMembers.value.length === 0) {
-    message?.warning('请选择要取消分配的人员')
-    return
-  }
-
   submitting.value = true
   try {
-    const productId =
-      selectedProduct.value || (assignedProducts.value.length > 0 ? assignedProducts.value[0].id : null)
+    const requestData = {
+      seats_id: selectedProduct.value,
+      assigned_to_user_id: selectedMembers.value
+    }
 
     if (operationMode.value === 'assign') {
+      await assign(requestData)
       message?.success(`成功为 ${selectedMembers.value.length} 位人员分配产品席位`)
     } else {
+      await cancel(requestData)
       message?.success(`成功取消 ${selectedMembers.value.length} 位人员的产品席位`)
     }
 
     emit('success', {
       mode: operationMode.value,
-      productId,
+      seatsId: selectedProduct.value,
       memberIds: selectedMembers.value
     })
     emit('update:visible', false)
@@ -875,8 +880,26 @@ const resetForm = () => {
           .product-meta {
             display: flex;
             align-items: center;
-            gap: 8px;
+            gap: 12px;
             font-size: 13px;
+            flex-wrap: wrap;
+
+            .card-type-badge {
+              display: inline-flex;
+              align-items: center;
+              gap: 4px;
+              padding: 4px 10px;
+              background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+              color: #fff;
+              border-radius: 12px;
+              font-weight: 500;
+              font-size: 12px;
+              box-shadow: 0 2px 6px rgba(245, 87, 108, 0.25);
+
+              i {
+                font-size: 13px;
+              }
+            }
 
             .seats-info {
               display: flex;
@@ -988,4 +1011,3 @@ const resetForm = () => {
   }
 }
 </style>
-