Browse Source

产品分配逻辑修改

“陈博论” 9 tháng trước cách đây
mục cha
commit
d7f28b90b2
1 tập tin đã thay đổi với 350 bổ sung và 111 xóa
  1. 350 111
      web/src/views/frontend/components/ProductSeatDialog.vue

+ 350 - 111
web/src/views/frontend/components/ProductSeatDialog.vue

@@ -38,7 +38,13 @@
           <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">
+              <img v-if="product.productImage" :src="product.productImage" :alt="product.name"
+                @error="handleImageError">
+              <div v-else class="product-image-placeholder">
+                <el-icon>
+                  <Goods />
+                </el-icon>
+              </div>
             </div>
             <div class="product-content">
               <div class="product-name">{{ product.name }}</div>
@@ -50,17 +56,32 @@
                   </el-icon>
                   <span>{{ product.cardTypeText }}</span>
                 </span>
-                <span class="seats-info">
+                <!-- 日期卡(cardType >= 3)显示席位信息 -->
+                <span v-if="product.cardType >= 3" class="seats-info">
                   <el-icon>
                     <UserFilled />
                   </el-icon>
                   <span v-if="operationMode === 'assign'">
-                    可用: {{ product.seats ?? 0 }}/{{ product.totalSeats ?? 0 }}
+                    可用: {{ product.seats ?? 0 }}
                   </span>
                   <span v-else>
-                    已分配: {{ product.assignedSeats || 0 }}/{{ product.totalSeats ?? 0 }}
+                    已分配: {{ product.assignedSeats || 0 }}
                   </span>
                 </span>
+                <!-- 次卡(cardType === 1)显示剩余次数 -->
+                <span v-else-if="product.cardType === 1 && product.remainingTimes !== undefined" class="times-info">
+                  <el-icon>
+                    <Ticket />
+                  </el-icon>
+                  <span>剩余: {{ product.remainingTimes }} 次</span>
+                </span>
+                <!-- 积分卡(cardType === 2)显示剩余积分 -->
+                <span v-else-if="product.cardType === 2 && product.remainingPoints !== undefined" class="points-info">
+                  <el-icon>
+                    <Ticket />
+                  </el-icon>
+                  <span>剩余: {{ product.remainingPoints }} 积分</span>
+                </span>
               </div>
             </div>
             <div v-if="selectedProduct === product.id" class="product-check">
@@ -97,20 +118,33 @@
           </div>
         </div>
         <div class="member-list-wrapper">
-          <div v-if="selectedProduct && selectedMembers.length > 0" class="selected-badge">
-            <el-icon>
-              <Check />
-            </el-icon>
-            <span>已选择 <strong>{{ selectedMembers.length }}</strong> 位成员</span>
+          <div v-if="selectedProduct" class="info-badges">
+            <div v-if="selectedMembers.length > 0" class="selected-badge">
+              <el-icon>
+                <Check />
+              </el-icon>
+              <span>已选择 <strong>{{ selectedMembers.length }}</strong> 位成员</span>
+            </div>
+            <!-- 只有日期卡才显示可用席位信息 -->
+            <div v-if="operationMode === 'assign' && isDateCard && currentAvailableSeats > 0" class="seats-badge">
+              <el-icon>
+                <UserFilled />
+              </el-icon>
+              <span>可用席位: <strong>{{ currentAvailableSeats }}</strong> 个</span>
+            </div>
           </div>
           <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) }">
-                  <el-checkbox :label="member.id" class="member-checkbox">
+              <el-checkbox-group v-model="selectedMembers">
+                <div v-for="member in currentMemberList" :key="member.id" class="member-card" :class="{
+                  selected: selectedMembers.includes(member.id),
+                  disabled: isMemberDisabled(member.id)
+                }">
+                  <el-checkbox :label="member.id" :disabled="isMemberDisabled(member.id)" class="member-checkbox">
                     <div class="member-avatar">
-                      <el-icon>
+                      <img v-if="member.avatar" :src="member.avatar" :alt="member.name"
+                        @error="handleMemberAvatarError">
+                      <el-icon v-else>
                         <UserFilled />
                       </el-icon>
                     </div>
@@ -123,6 +157,9 @@
                         <Check />
                       </el-icon>
                     </div>
+                    <div v-if="isMemberDisabled(member.id)" class="member-disabled-badge">
+                      {{ isDateCard ? '席位已满' : '不可选择' }}
+                    </div>
                   </el-checkbox>
                 </div>
               </el-checkbox-group>
@@ -203,24 +240,47 @@ const currentProductList = computed(() =>
 // 当前显示的人员列表
 const currentMemberList = computed(() => {
   if (!selectedProduct.value) return []
-  if (operationMode.value === 'assign') {
-    // 分配模式:显示未分配的成员列表
-    return props.memberList
-  }
-  // 取消分配模式:显示该套餐已分配的成员列表
+  // 分配模式和取消分配模式都从 productMembersMap 中获取对应的成员列表
   return productMembersMap.value[selectedProduct.value] || []
 })
 
-const canSubmit = computed(() => {
+// 当前选中的产品信息
+const currentSelectedProduct = computed(() => {
+  if (!selectedProduct.value) return null
   if (operationMode.value === 'assign') {
-    // 分配模式:需要选择人员且选择产品
-    return selectedMembers.value.length > 0 && !!selectedProduct.value
+    return availableProducts.value.find(p => p.id === selectedProduct.value)
   } else {
-    // 取消分配模式:需要选择产品且选择人员
-    return !!selectedProduct.value && selectedMembers.value.length > 0
+    return assignedProducts.value.find(p => p.id === selectedProduct.value)
   }
 })
 
+// 判断当前选中的产品是否是日期卡(cardType >= 3)
+const isDateCard = computed(() => {
+  return currentSelectedProduct.value?.cardType >= 3
+})
+
+// 当前选中产品的可用席位数(仅日期卡有效)
+const currentAvailableSeats = computed(() => {
+  if (!selectedProduct.value || operationMode.value !== 'assign' || !isDateCard.value) return 0
+  return currentSelectedProduct.value?.availableSeats || 0
+})
+
+
+// 是否禁用某个成员的选择(只有日期卡才进行席位限制)
+const isMemberDisabled = (memberId) => {
+  if (operationMode.value !== 'assign') return false
+  // 次卡/积分卡不限制,不禁用
+  if (!isDateCard.value) return false
+  // 如果该成员已被选中,则不禁用(允许取消选择)
+  if (selectedMembers.value.includes(memberId)) return false
+  // 日期卡:如果已选人数达到可用席位数,则禁用未选中的成员
+  return selectedMembers.value.length >= currentAvailableSeats.value
+}
+
+const canSubmit = computed(() => {
+  return selectedMembers.value.length > 0 && !!selectedProduct.value
+})
+
 const message = proxy?.$message
 
 watch(
@@ -244,52 +304,80 @@ watch(
   { immediate: true, deep: true }
 )
 
+// 映射产品数据
+const mapProductData = (pkg) => ({
+  id: pkg.seats_id,
+  name: pkg.product_name,
+  description: `${pkg.spec_name} - ${pkg.card_type_text}`,
+  seats: pkg.available_seats,
+  productImage: pkg.product_image || pkg.image || '',
+  packagePriceId: pkg.package_price_id,
+  productId: pkg.product_id,
+  productType: pkg.product_type,
+  productLink: pkg.product_link,
+  cardType: pkg.card_type,
+  cardTypeText: pkg.card_type_text,
+  totalSeats: pkg.total_seats,
+  assignedSeats: pkg.assigned_seats,
+  availableSeats: pkg.available_seats,
+  totalTimes: pkg.total_times,
+  remainingTimes: pkg.remaining_times,
+  totalPoints: pkg.total_points,
+  remainingPoints: pkg.remaining_points,
+  endTime: pkg.end_time,
+  allocatedCount: pkg.allocated_count,
+  unallocatedCount: pkg.unallocated_count,
+  totalMembers: pkg.total_members
+})
+
+// 映射成员数据
+const mapMemberData = (member, seatsId, packagePriceId) => ({
+  id: member.user_id,
+  name: member.member_name,
+  email: member.email,
+  avatar: member.avatar || '',
+  mobile: member.mobile || '',
+  role: member.role,
+  ...(member.allocation_id && { allocation_id: member.allocation_id }),
+  seats_id: seatsId,
+  package_price_id: packagePriceId
+})
+
 const initDialog = async () => {
-  selectedMembers.value = props.selectedMemberIds.length > 0 ? [...props.selectedMemberIds] : []
   if (operationMode.value === 'assign') {
     await loadAvailableProducts()
   } else {
     await loadAssignedProducts()
   }
+  if (props.selectedMemberIds.length > 0) {
+    selectedMembers.value = [...props.selectedMemberIds]
+  }
 }
 
 const loadAvailableProducts = async () => {
   try {
     const res = await allocationList()
-    if (res.data && res.data.packages) {
-      // 筛选出有可用席位的套餐(用于分配模式)
+    if (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)
-      }
+        .filter(pkg => {
+          if (pkg.card_type >= 3) {
+            return pkg.available_seats > 0
+          }
+          return pkg.unallocated_members?.length > 0
+        })
+        .map(mapProductData)
+
+      res.data.packages.forEach(pkg => {
+        if (pkg.unallocated_members?.length > 0) {
+          productMembersMap.value[pkg.seats_id] = pkg.unallocated_members.map(member =>
+            mapMemberData(member, pkg.seats_id, pkg.package_price_id)
+          )
+        }
+      })
 
       autoSelectFirstProduct()
     }
-  } catch (error) {
-    console.error('加载可分配套餐失败:', error)
+  } catch {
     message?.error('加载可分配套餐失败')
   }
 }
@@ -297,50 +385,31 @@ const loadAvailableProducts = async () => {
 const loadAssignedProducts = async () => {
   try {
     const res = await allocationList()
-    if (res.data && res.data.packages) {
-      // 筛选出已有分配成员的套餐(用于取消分配模式)
+    if (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
-        }))
-
-      // 存储每个套餐的已分配成员列表
+        .filter(pkg => pkg.allocated_members?.length > 0)
+        .map(mapProductData)
+
       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
-          }))
+        if (pkg.allocated_members?.length > 0) {
+          productMembersMap.value[pkg.seats_id] = pkg.allocated_members.map(member =>
+            mapMemberData(member, pkg.seats_id, pkg.package_price_id)
+          )
         }
       })
 
       autoSelectFirstProduct()
     }
-  } catch (error) {
-    console.error('加载已分配套餐失败:', error)
+  } catch {
     message?.error('加载已分配套餐失败')
   }
 }
 
 const autoSelectFirstProduct = () => {
-  if (currentProductList.value.length > 0) {
-    selectedProduct.value = currentProductList.value[0].id
-  } else {
-    selectedProduct.value = null
+  selectedProduct.value = currentProductList.value[0]?.id || null
+  if (props.selectedMemberIds.length === 0) {
+    selectedMembers.value = []
   }
-  selectedMembers.value = []
 }
 
 // 选择产品时,更新人员列表
@@ -353,6 +422,10 @@ const handleModeChange = async () => {
   // 切换模式时重置状态并重新拉取对应产品列表
   selectedProduct.value = null
   selectedMembers.value = []
+  // 清空产品列表和成员映射,避免数据混乱
+  availableProducts.value = []
+  assignedProducts.value = []
+  productMembersMap.value = {}
   if (operationMode.value === 'assign') {
     await loadAvailableProducts()
   } else {
@@ -360,19 +433,48 @@ const handleModeChange = async () => {
   }
 }
 
-const handleMemberChange = () => {
-  // 人员选择变化时的处理
-}
-
 const handleSelectAll = () => {
-  selectedMembers.value = currentMemberList.value.map(m => m.id)
+  if (operationMode.value === 'assign') {
+    // 分配模式
+    if (isDateCard.value) {
+      // 日期卡:最多只能选择可用席位数量的人员
+      const maxSelectable = Math.min(currentMemberList.value.length, currentAvailableSeats.value)
+      selectedMembers.value = currentMemberList.value.slice(0, maxSelectable).map(m => m.id)
+      if (currentMemberList.value.length > currentAvailableSeats.value) {
+        message?.warning(`可用席位仅剩 ${currentAvailableSeats.value} 个,已为您选择前 ${maxSelectable} 位成员`)
+      }
+    } else {
+      // 次卡/积分卡:不限制,可以全选
+      selectedMembers.value = currentMemberList.value.map(m => m.id)
+    }
+  } else {
+    // 取消分配模式:可以全选
+    selectedMembers.value = currentMemberList.value.map(m => m.id)
+  }
 }
 
 const handleSelectNone = () => {
   selectedMembers.value = []
 }
 
-const getProductImage = productId => `https://picsum.photos/200/200?random=${productId}`
+// 处理产品图片加载错误
+const handleImageError = (event) => {
+  event.target.style.display = 'none'
+  const placeholder = event.target.nextElementSibling
+  if (placeholder && placeholder.classList.contains('product-image-placeholder')) {
+    placeholder.style.display = 'flex'
+  }
+}
+
+// 处理成员头像加载错误
+const handleMemberAvatarError = (event) => {
+  event.target.style.display = 'none'
+  // 显示默认图标
+  const icon = event.target.nextElementSibling
+  if (icon) {
+    icon.style.display = 'flex'
+  }
+}
 
 const handleSubmit = async () => {
   if (!selectedProduct.value) {
@@ -387,17 +489,77 @@ const handleSubmit = async () => {
 
   submitting.value = true
   try {
-    const requestData = {
-      seats_id: selectedProduct.value,
-      assigned_to_user_id: selectedMembers.value
-    }
-
     if (operationMode.value === 'assign') {
-      await assign(requestData)
-      message?.success(`成功为 ${selectedMembers.value.length} 位人员分配产品席位`)
+      // 分配模式:后端只支持单个用户分配,需要循环调用
+      let successCount = 0
+      let failCount = 0
+
+      for (const userId of selectedMembers.value) {
+        try {
+          await assign({
+            seats_id: selectedProduct.value,
+            assigned_to_user_id: userId
+          })
+          successCount++
+        } catch {
+          failCount++
+        }
+      }
+
+      // 只显示一次提示,根据情况选择最合适的提示
+      if (successCount > 0 && failCount > 0) {
+
+      } else if (successCount > 0) {
+
+      } else {
+        // 全部失败
+        message?.error('分配席位失败,请重试')
+        return
+      }
     } else {
-      await cancel(requestData)
-      message?.success(`成功取消 ${selectedMembers.value.length} 位人员的产品席位`)
+      // 取消分配模式:需要 allocation_id
+      // 后端已返回 allocation_id 字段,直接使用
+      const currentMembers = currentMemberList.value
+      let successCount = 0
+      let failCount = 0
+      let missingAllocationIdCount = 0
+
+      for (const userId of selectedMembers.value) {
+        const member = currentMembers.find(m => m.id === userId)
+        if (!member) {
+          failCount++
+          continue
+        }
+
+        const allocationId = member.allocation_id
+        if (allocationId) {
+          try {
+            await cancel({ allocation_id: allocationId })
+            successCount++
+          } catch {
+            failCount++
+          }
+        } else {
+          missingAllocationIdCount++
+        }
+      }
+
+      const totalFailCount = failCount + missingAllocationIdCount
+
+      if (successCount > 0 && totalFailCount > 0) {
+        const failDetails = []
+        if (failCount > 0) failDetails.push(`${failCount} 位操作失败`)
+        if (missingAllocationIdCount > 0) failDetails.push(`${missingAllocationIdCount} 位缺少分配记录ID`)
+        // message?.success(`成功取消 ${successCount} 位人员的产品席位,${failDetails.join(',')}`)
+      } else if (successCount > 0) {
+        // message?.success(`成功取消 ${successCount} 位人员的产品席位`)
+      } else if (totalFailCount > 0) {
+        const failDetails = []
+        if (failCount > 0) failDetails.push(`${failCount} 位操作失败`)
+        if (missingAllocationIdCount > 0) failDetails.push(`${missingAllocationIdCount} 位缺少分配记录ID`)
+        message?.error(`取消分配失败:${failDetails.join(',')}。请刷新页面后重试。`)
+        return
+      }
     }
 
     emit('success', {
@@ -406,9 +568,6 @@ const handleSubmit = async () => {
       memberIds: selectedMembers.value
     })
     emit('update:visible', false)
-  } catch (error) {
-    console.error('操作失败:', error)
-    message?.error(operationMode.value === 'assign' ? '分配席位失败' : '取消分配失败')
   } finally {
     submitting.value = false
   }
@@ -427,6 +586,7 @@ const resetForm = () => {
   operationMode.value = 'assign'
   selectedMembers.value = []
   selectedProduct.value = null
+  availableProducts.value = []
   assignedProducts.value = []
   productMembersMap.value = {}
   submitting.value = false
@@ -571,17 +731,21 @@ const resetForm = () => {
   // 人员选择区域
   .member-section {
     .member-list-wrapper {
-      .selected-badge {
+      .info-badges {
+        display: flex;
+        gap: 12px;
+        margin-bottom: 16px;
+        flex-wrap: wrap;
+      }
+
+      .selected-badge,
+      .seats-badge {
         display: inline-flex;
         align-items: center;
         gap: 6px;
         padding: 8px 16px;
-        background: linear-gradient(135deg, #ecf5ff 0%, #e1f3ff 100%);
-        border: 1px solid #b3d8ff;
         border-radius: 20px;
-        margin-bottom: 16px;
         font-size: 14px;
-        color: #409eff;
 
         i {
           font-size: 16px;
@@ -593,6 +757,18 @@ const resetForm = () => {
         }
       }
 
+      .selected-badge {
+        background: linear-gradient(135deg, #ecf5ff 0%, #e1f3ff 100%);
+        border: 1px solid #b3d8ff;
+        color: #409eff;
+      }
+
+      .seats-badge {
+        background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+        border: 1px solid #bae6fd;
+        color: #0284c7;
+      }
+
       .member-list-scroll {
         max-height: 320px;
         overflow-y: auto;
@@ -656,6 +832,22 @@ const resetForm = () => {
             transform: translateY(-1px);
           }
 
+          &.disabled {
+            opacity: 0.5;
+            cursor: not-allowed;
+            background: #f5f7fa;
+
+            &:hover {
+              border-color: #e4e7ed;
+              box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+              transform: none;
+            }
+
+            .member-checkbox {
+              cursor: not-allowed;
+            }
+          }
+
           ::v-deep .member-checkbox {
             width: 100%;
             margin: 0;
@@ -688,6 +880,14 @@ const resetForm = () => {
             flex-shrink: 0;
             box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
             transition: transform 0.2s ease;
+            overflow: hidden;
+
+            img {
+              width: 100%;
+              height: 100%;
+              object-fit: cover;
+              display: block;
+            }
 
             i {
               font-size: 22px;
@@ -744,6 +944,17 @@ const resetForm = () => {
               color: #fff;
             }
           }
+
+          .member-disabled-badge {
+            padding: 4px 10px;
+            background: #f56c6c;
+            color: #fff;
+            border-radius: 12px;
+            font-size: 12px;
+            font-weight: 500;
+            white-space: nowrap;
+            flex-shrink: 0;
+          }
         }
 
         .empty-member {
@@ -844,6 +1055,20 @@ const resetForm = () => {
             display: block;
           }
 
+          .product-image-placeholder {
+            width: 100%;
+            height: 100%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
+            color: #c0c4cc;
+
+            i {
+              font-size: 48px;
+            }
+          }
+
           &::after {
             content: '';
             position: absolute;
@@ -852,6 +1077,7 @@ const resetForm = () => {
             right: 0;
             bottom: 0;
             background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.02) 100%);
+            pointer-events: none;
           }
         }
 
@@ -901,17 +1127,30 @@ const resetForm = () => {
               }
             }
 
-            .seats-info {
+            .seats-info,
+            .times-info,
+            .points-info {
               display: flex;
               align-items: center;
               gap: 4px;
-              color: #409eff;
               font-weight: 500;
 
               i {
                 font-size: 14px;
               }
             }
+
+            .seats-info {
+              color: #409eff;
+            }
+
+            .times-info {
+              color: #67c23a;
+            }
+
+            .points-info {
+              color: #e6a23c;
+            }
           }
         }