Browse Source

feat(hb): 完善红包发放与到账提醒

- hdApp 新增红包统计、自动发放配置、手动批量发放和客户多选流程
- mallApp 新增红包到账提醒,并按商品范围、活动限制和最低消费统一筛选可用红包
- 补充红包接口、路由、图标及 H5 构建产物忽略规则
shizhongqi 3 days ago
parent
commit
7c25b42584

+ 116 - 10
hdApp/src/admin/custom/selectCustom.vue

@@ -1,3 +1,7 @@
+<!--
+  客户选择页面,供开单、回填、红包及计算器等业务选择客户。
+  在保留原单选跳转行为的基础上,为红包发送增加多选确认模式。
+-->
 <template>
     <view class="app-main app-content">
       <view class="input-wrap_box">
@@ -7,9 +11,12 @@
       </view>
       <block v-if="!$util.isEmpty(list.data)">
         <block v-for="(item, index) in list.data" :key="index">
-          <tui-list-cell :arrow="true" @click="selectCurrent(item)">
+          <tui-list-cell :arrow="!isHbMulti" @click="selectCurrent(item)">
             <view class="tui-msg-box">
-              <img :src="item.avatar" class="tui-msg-pic" mode="widthFix"/>
+              <view v-if="isHbMulti" class="multi-checkbox" :class="{ checked: isSelected(item) }">
+                <text v-if="isSelected(item)">✓</text>
+              </view>
+              <image :src="item.avatar" class="tui-msg-pic" mode="aspectFill"/>
               <view class="tui-msg-item" style="position:relative;">
                 <view class="tui-msg-name">
                   <view class="tui-user-name">
@@ -17,12 +24,12 @@
                   </view>
                 </view>
                 <view class="tui-msg-content">
-                  <span>{{ item.visitTime.substr(5,11) }}</span>
+                  <text>{{ item.visitTime.substr(5,11) }}</text>
   
-                    <span v-if="Number(item.balance)!=0" class="balance-amount">
-                      <span v-if="item.balance>0" class="amount_add">余额{{item.balance?parseFloat(item.balance):0}}</span>
-                      <span v-else class="amount_sub">欠款{{item.balance?Math.abs(parseFloat(item.balance)):0}}</span>
-                    </span>
+                    <text v-if="Number(item.balance)!=0" class="balance-amount">
+                      <text v-if="item.balance>0" class="amount_add">余额{{item.balance?parseFloat(item.balance):0}}</text>
+                      <text v-else class="amount_sub">欠款{{item.balance?Math.abs(parseFloat(item.balance)):0}}</text>
+                    </text>
   
                     <text v-if="item.overTimeUnExpend && item.overTimeUnExpend == 10000" style="font-size:24upx;margin-left:20upx;color:green;">没有下过单</text>
                     <text v-if="item.overTimeUnExpend && item.overTimeUnExpend == 7" style="font-size:24upx;margin-left:20upx;color:green;">超1周未下单</text>
@@ -43,6 +50,9 @@
       <block v-else>
         <app-wrapper-empty :is-empty="$util.isEmpty(list.data)" />
       </block>
+      <view v-if="isHbMulti" class="multi-footer">
+        <button class="confirm-multi-btn" @click="confirmMulti">确定(已选{{ selectedCount }}人)</button>
+      </view>
     </view>
   </template>
   <script>
@@ -63,8 +73,7 @@
       AppWrapperEmpty,
       appVipModule,
       AppTag,
-      AppSearchModule,
-      couldScan:false
+      AppSearchModule
     },
     mixins: [list],
     data() {
@@ -72,10 +81,24 @@
         tabIndex: 0,
         selectStyle: 1,
         option: {},
+        selectedMap: {},
+        couldScan: false
       };
     },
     computed:{
       ...mapGetters(["getMyShopInfo"]),
+      /**
+       * 判断是否为红包客户多选模式,style=5 未带 multi 时继续走旧单选流程。
+       */
+      isHbMulti() {
+        return String(this.option.style) === '5' && String(this.option.multi) === '1'
+      },
+      /**
+       * 返回当前已选客户数量,供底部按钮实时展示。
+       */
+      selectedCount() {
+        return Object.keys(this.selectedMap).length
+      }
     },
     onPullDownRefresh() {
       this.resetList();
@@ -104,9 +127,16 @@
           uni.removeStorageSync('newClient');
       },
     methods: {
+      /**
+       * 处理客户选择;红包多选只切换选中状态,其余场景保持原跳转或回填逻辑。
+       */
       selectCurrent(item,scanEnv=0){
         let style = this.option.style != null && this.option.style !== '' ? this.option.style : 1
         this.selectStyle = style
+        if (this.isHbMulti) {
+          this.toggleSelected(item)
+          return
+        }
         if(style == 1){
           //花材开单
           this.$util.pageTo({url: '/admin/billing/index2?customId='+item.id+'&customName='+item.name,type:2})
@@ -134,6 +164,40 @@
           //无操作
         }
       },
+      /**
+       * 判断客户是否已在红包多选集合中。
+       */
+      isSelected(item) {
+        return !!this.selectedMap[String(item.id)]
+      },
+      /**
+       * 切换客户选中状态,保存接口提交所需的用户与客户标识。
+       */
+      toggleSelected(item) {
+        const key = String(item.id)
+        if (this.selectedMap[key]) {
+          this.$delete(this.selectedMap, key)
+          return
+        }
+        this.$set(this.selectedMap, key, {
+          id: item.id,
+          userId: item.userId,
+          name: item.name,
+          customId: item.id
+        })
+      },
+      /**
+       * 将红包多选结果暂存后返回发送页,空选择时提示用户继续选择。
+       */
+      confirmMulti() {
+        const selected = Object.keys(this.selectedMap).map((key) => this.selectedMap[key])
+        if (!selected.length) {
+          this.$msg('请至少选择一位客户')
+          return
+        }
+        uni.setStorageSync('hb_selected_customs', JSON.stringify(selected))
+        uni.navigateBack()
+      },
       init() {
         if(!this.no_shop_show_model) {
           this._list();
@@ -172,7 +236,7 @@
   <style lang="scss" scoped>
   .app-content {
     padding-top: 100upx;
-    padding-bottom: 20upx;
+    padding-bottom: 130upx;
   }
   .tabs-wrap {
     position: fixed;
@@ -209,6 +273,25 @@
   .tui-msg-box {
     display: flex;
     align-items: center;
+
+    .multi-checkbox {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 36upx;
+      height: 36upx;
+      margin-right: 18upx;
+      border: 2upx solid #bbb;
+      border-radius: 6upx;
+      color: #fff;
+      font-size: 24upx;
+      box-sizing: border-box;
+
+      &.checked {
+        border-color: #09c567;
+        background: #09c567;
+      }
+    }
   
     .tui-msg-pic {
       width: 80upx;
@@ -347,4 +430,27 @@
     display: inline-block;
     font-size: 30upx;
   }
+  .multi-footer {
+    position: fixed;
+    z-index: 20;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    padding: 20upx 30upx;
+    background: #fff;
+    box-shadow: 0 -2upx 12upx rgba(0, 0, 0, 0.08);
+
+    .confirm-multi-btn {
+      height: 80upx;
+      line-height: 80upx;
+      border-radius: 40upx;
+      background: #09c567;
+      color: #fff;
+      font-size: 30upx;
+
+      &::after {
+        border: none;
+      }
+    }
+  }
   </style>

+ 426 - 0
hdApp/src/admin/hb/autoSend/edit.vue

@@ -0,0 +1,426 @@
+<!--
+  自动发红包配置页,供门店管理员配置四类自动发放规则。
+  复用红包基础设置和适用范围,并按沉睡召回、会员日场景补充专属条件。
+-->
+<template>
+  <view class="edit-page">
+    <view class="status-card">
+      <view>
+        <view class="status-title">自动发放状态</view>
+        <view class="status-tip">{{ status === 1 ? '规则满足后将自动发放红包' : '当前规则不会自动发放' }}</view>
+      </view>
+      <view class="status-switch">
+        <text>{{ status === 1 ? '已开启' : '已关闭' }}</text>
+        <switch color="#09c567" :checked="status === 1" @change="changeStatus" />
+      </view>
+    </view>
+
+    <hb-base-setting v-model="rules" />
+
+    <hb-scope-picker
+      :scope-type="scopeType"
+      :scope-value="scopeValue"
+      :special-applicable="specialApplicable"
+      @change="onScopeChange"
+    />
+
+    <view v-if="type === 3" class="scene-section">
+      <view class="section-title">召回条件</view>
+      <view class="scene-card">
+        <view class="form-row">
+          <text class="label">未下单时长</text>
+          <input class="input" type="number" v-model="extra.recallDays" placeholder="请输入天数" />
+          <text class="unit">天</text>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="type === 4" class="scene-section">
+      <view class="section-title">定时发送设置</view>
+      <view class="scene-card">
+        <picker :range="dayOptions" :value="dayPickerIndex" @change="changeSendDay">
+          <view class="form-row">
+            <text class="label">发送日期</text>
+            <text class="picker-value">每月{{ extra.sendDay }}号 ›</text>
+          </view>
+        </picker>
+        <picker mode="time" :value="extra.sendTime" @change="changeSendTime">
+          <view class="form-row">
+            <text class="label">发送时间</text>
+            <text class="picker-value">{{ extra.sendTime || '请选择时间' }} ›</text>
+          </view>
+        </picker>
+        <view class="target-row">
+          <text class="label">发送对象</text>
+          <view class="target-options">
+            <view class="target-option" @click="extra.targetType = 1">
+              <view class="radio-dot" :class="{ active: extra.targetType === 1 }"></view>
+              <text>全部客户</text>
+            </view>
+            <view v-if="isVipMember" class="target-option" @click="extra.targetType = 2">
+              <view class="radio-dot" :class="{ active: extra.targetType === 2 }"></view>
+              <text>仅会员</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="footer-placeholder"></view>
+    <view class="footer-bar">
+      <button class="save-btn" :disabled="saving" @click="save">保存</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getAutoRuleDetail, saveAutoRule } from '@/api/hb'
+import { getRechargeSetting } from '@/api/shop'
+import HbBaseSetting from '../components/hbBaseSetting.vue'
+import HbScopePicker from '../components/hbScopePicker.vue'
+
+/**
+ * 自动红包配置页。
+ * 根据 type 加载规则详情,会员日额外校验会员功能开关,保存时提交统一规则和场景配置。
+ */
+export default {
+  components: {
+    HbBaseSetting,
+    HbScopePicker
+  },
+  data() {
+    return {
+      type: 1,
+      status: 0,
+      rules: [{
+        name: '',
+        hbAmount: '',
+        hbNum: 1,
+        miniCost: '',
+        duration: '',
+        effectiveType: 1,
+        effectiveDate: '',
+        effectiveDays: 0
+      }],
+      scopeType: 1,
+      scopeValue: '',
+      specialApplicable: 1,
+      extra: {
+        recallDays: '',
+        sendDay: 1,
+        sendTime: '09:00',
+        targetType: 1
+      },
+      isVipMember: false,
+      dayOptions: Array.from({ length: 28 }, (item, index) => index + 1),
+      saving: false
+    }
+  },
+  computed: {
+    dayPickerIndex() {
+      const index = this.dayOptions.indexOf(Number(this.extra.sendDay))
+      return index > -1 ? index : 0
+    }
+  },
+  onLoad(options) {
+    this.type = Number(options.type) || 1
+    const names = {
+      1: '配置新客红包',
+      2: '配置受邀新客红包',
+      3: '配置沉睡召回红包',
+      4: '配置会员日红包'
+    }
+    uni.setNavigationBarTitle({ title: names[this.type] || '配置红包' })
+    if (this.type === 4) this.loadRechargeSetting()
+    this.loadDetail()
+  },
+  methods: {
+    /**
+     * 加载自动规则详情,兼容 rules/extra 为 JSON 字符串的存储格式。
+     */
+    loadDetail() {
+      getAutoRuleDetail({ type: this.type }).then((res) => {
+        if (res.code != 1 || !res.data) return
+        const data = res.data
+        this.status = Number(data.status) === 1 ? 1 : 0
+        const rules = this.parseJson(data.rules, [])
+        if (Array.isArray(rules) && rules.length) this.rules = rules
+        this.scopeType = Number(data.scopeType) || 1
+        this.scopeValue = data.scopeValue || ''
+        this.specialApplicable = data.specialApplicable === undefined ? 1 : (Number(data.specialApplicable) ? 1 : 0)
+        const extra = this.parseJson(data.extra, {})
+        this.extra = Object.assign({}, this.extra, {
+          recallDays: extra.recallDays !== undefined ? extra.recallDays : (extra.unOrderDays || ''),
+          sendDay: Number(extra.sendDay || extra.monthDay) || 1,
+          sendTime: extra.sendTime || '09:00',
+          // 后端字段名为 target:1全部客户 2仅会员
+          targetType: Number(extra.target || extra.targetType || extra.sendObject) || 1
+        })
+      })
+    },
+    /**
+     * 查询充值会员功能;未开启时会员日发送对象强制为全部客户。
+     */
+    loadRechargeSetting() {
+      getRechargeSetting().then((res) => {
+        const data = res.data || {}
+        this.isVipMember = Number(data.isVipMember) === 1
+        if (!this.isVipMember) this.extra.targetType = 1
+      })
+    },
+    /**
+     * 安全解析接口中的数组或对象 JSON 字段。
+     */
+    parseJson(value, fallback) {
+      if (!value) return fallback
+      if (typeof value !== 'string') return value
+      try {
+        return JSON.parse(value)
+      } catch (error) {
+        return fallback
+      }
+    },
+    /**
+     * 切换自动规则启停状态。
+     */
+    changeStatus(event) {
+      this.status = event.detail.value ? 1 : 0
+    },
+    /**
+     * 接收适用范围组件的完整配置。
+     */
+    onScopeChange(value) {
+      this.scopeType = value.scopeType
+      this.scopeValue = value.scopeValue
+      this.specialApplicable = value.specialApplicable
+    },
+    /**
+     * 更新会员日每月发送日期;限定 1 至 28 号保证所有月份均可执行。
+     */
+    changeSendDay(event) {
+      this.extra.sendDay = this.dayOptions[Number(event.detail.value)]
+    },
+    /**
+     * 更新会员日发送时间。
+     */
+    changeSendTime(event) {
+      this.extra.sendTime = event.detail.value
+    },
+    /**
+     * 校验红包基础字段,避免保存无法发放的自动规则。
+     */
+    validateRules() {
+      if (!this.rules.length) {
+        this.$msg('请至少配置一个红包')
+        return false
+      }
+      for (let i = 0; i < this.rules.length; i++) {
+        const item = this.rules[i]
+        const prefix = `第${i + 1}个红包`
+        if (!item.hbAmount || Number(item.hbAmount) <= 0) {
+          this.$msg(`${prefix}请输入正确金额`)
+          return false
+        }
+        if (item.miniCost === '' || Number(item.miniCost) < 0) {
+          this.$msg(`${prefix}请输入最低消费`)
+          return false
+        }
+        if (!Number.isInteger(Number(item.hbNum)) || Number(item.hbNum) < 1) {
+          this.$msg(`${prefix}发放数量必须为正整数`)
+          return false
+        }
+        if (!Number.isInteger(Number(item.duration)) || Number(item.duration) < 0) {
+          this.$msg(`${prefix}有效时长必须为非负整数`)
+          return false
+        }
+      }
+      return true
+    },
+    /**
+     * 校验范围和场景条件后保存自动规则;额外字段保留兼容别名供后端调度读取。
+     */
+    save() {
+      if (this.saving) return
+      if (!this.validateRules()) return
+      if (this.scopeType !== 1 && !this.scopeValue) {
+        this.$msg(this.scopeType === 2 ? '请选择花束分类' : '请选择花束商品')
+        return
+      }
+      if (this.type === 3 && (!Number.isInteger(Number(this.extra.recallDays)) || Number(this.extra.recallDays) < 1)) {
+        this.$msg('未下单时长必须为正整数')
+        return
+      }
+      if (this.type === 4 && !this.extra.sendTime) {
+        this.$msg('请选择发送时间')
+        return
+      }
+      if (!this.isVipMember) this.extra.targetType = 1
+      // 写入后端约定字段:沉睡 unOrderDays;会员日 sendDay/sendTime/target
+      const extra = Object.assign({}, this.extra, {
+        unOrderDays: this.extra.recallDays,
+        sendDay: this.extra.sendDay,
+        monthDay: this.extra.sendDay,
+        sendTime: this.extra.sendTime,
+        target: this.extra.targetType,
+        sendObject: this.extra.targetType
+      })
+      this.saving = true
+      saveAutoRule({
+        type: this.type,
+        status: this.status,
+        rules: this.rules,
+        scopeType: this.scopeType,
+        scopeValue: this.scopeValue,
+        specialApplicable: this.specialApplicable,
+        extra
+      }).then((res) => {
+        if (res.code == 1) {
+          this.$msg('保存成功')
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 800)
+        }
+      }, () => {
+        // 网络异常由全局请求层提示,此处仅恢复按钮状态。
+      }).then(() => {
+        this.saving = false
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.edit-page {
+  min-height: 100vh;
+  padding: 24upx;
+  background: #f5f6f7;
+  box-sizing: border-box;
+}
+.status-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 28upx;
+  padding: 30upx;
+  border-radius: 16upx;
+  background: #fff;
+}
+.status-title {
+  color: #222;
+  font-size: 31upx;
+  font-weight: bold;
+}
+.status-tip {
+  margin-top: 9upx;
+  color: #999;
+  font-size: 23upx;
+}
+.status-switch {
+  display: flex;
+  align-items: center;
+  color: #09c567;
+  font-size: 25upx;
+}
+.status-switch switch {
+  margin-left: 14upx;
+}
+.scene-section {
+  margin-bottom: 24upx;
+}
+.section-title {
+  margin-bottom: 20upx;
+  padding-left: 16upx;
+  border-left: 8upx solid #09c567;
+  color: #222;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.scene-card {
+  overflow: hidden;
+  border-radius: 16upx;
+  background: #fff;
+}
+.form-row,
+.target-row {
+  display: flex;
+  align-items: center;
+  min-height: 94upx;
+  padding: 0 30upx;
+  border-bottom: 1upx solid #f5f5f5;
+}
+.label {
+  width: 190upx;
+  color: #333;
+  font-size: 29upx;
+}
+.input {
+  flex: 1;
+  text-align: right;
+  font-size: 28upx;
+}
+.unit {
+  margin-left: 10upx;
+  color: #666;
+  font-size: 26upx;
+}
+.picker-value {
+  flex: 1;
+  color: #555;
+  text-align: right;
+  font-size: 27upx;
+}
+.target-row {
+  align-items: flex-start;
+  padding-top: 28upx;
+  padding-bottom: 28upx;
+}
+.target-options {
+  flex: 1;
+}
+.target-option {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20upx;
+  color: #555;
+  font-size: 27upx;
+}
+.target-option:last-child {
+  margin-bottom: 0;
+}
+.radio-dot {
+  width: 30upx;
+  height: 30upx;
+  margin-right: 15upx;
+  border: 2upx solid #bbb;
+  border-radius: 50%;
+  box-sizing: border-box;
+}
+.radio-dot.active {
+  border: 9upx solid #09c567;
+}
+.footer-placeholder {
+  height: 125upx;
+}
+.footer-bar {
+  position: fixed;
+  z-index: 90;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: 20upx 36upx;
+  background: #fff;
+  box-shadow: 0 -3upx 15upx rgba(0, 0, 0, 0.06);
+}
+.save-btn {
+  height: 82upx;
+  line-height: 82upx;
+  border-radius: 42upx;
+  background: #09c567;
+  color: #fff;
+  font-size: 30upx;
+}
+.save-btn::after {
+  border: 0;
+}
+</style>

+ 234 - 0
hdApp/src/admin/hb/autoSend/list.vue

@@ -0,0 +1,234 @@
+<!--
+  自动发红包规则列表,供门店管理员查看四类自动红包的配置状态和摘要。
+  固定展示全部业务类型,即使接口尚未生成规则也可直接进入配置。
+-->
+<template>
+  <view class="auto-list-page">
+    <view v-for="item in cards" :key="item.type" class="rule-card">
+      <view class="card-header">
+        <view>
+          <view class="type-name">{{ item.typeName }}</view>
+          <view class="type-desc">{{ typeDescription(item.type) }}</view>
+        </view>
+        <text class="status" :class="item.statusClass">{{ item.statusName }}</text>
+      </view>
+      <view class="summary-row">
+        <view class="summary-item">
+          <text class="summary-value">¥{{ ruleValue(item, 'hbAmount', '0.00') }}</text>
+          <text class="summary-label">红包金额</text>
+        </view>
+        <view class="summary-item">
+          <text class="summary-value">¥{{ ruleValue(item, 'miniCost', '0.00') }}</text>
+          <text class="summary-label">最低消费</text>
+        </view>
+      </view>
+      <view class="scope-row">
+        <text class="scope-label">适用范围</text>
+        <text class="scope-value">{{ item.scopeText || formatScope(item) }}</text>
+      </view>
+      <button class="config-btn" @click="goEdit(item.type)">去配置</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getAutoRuleList } from '@/api/hb'
+
+/**
+ * 自动红包规则列表页。
+ * 以四种固定业务类型为骨架合并接口数据,确保未配置规则也始终有入口。
+ */
+export default {
+  data() {
+    return {
+      defaultCards: [
+        { type: 1, typeName: '新客红包', status: -1 },
+        { type: 2, typeName: '受邀新客红包', status: -1 },
+        { type: 3, typeName: '沉睡召回红包', status: -1 },
+        { type: 4, typeName: '会员日红包', status: -1 }
+      ],
+      apiCards: []
+    }
+  },
+  computed: {
+    /**
+     * 合并默认骨架与接口数据,并预计算状态文案/样式类。
+     * 微信小程序 :class 不支持方法调用,故在此提前算好。
+     */
+    cards() {
+      return this.defaultCards.map((base) => {
+        const current = this.apiCards.find((item) => Number(item.type) === base.type)
+        const card = Object.assign({}, base, current || {})
+        card.statusName = this.statusName(card.status)
+        card.statusClass = this.statusClass(card.status)
+        return card
+      })
+    }
+  },
+  onShow() {
+    this.loadList()
+  },
+  methods: {
+    /**
+     * 拉取自动规则卡片,兼容接口直接返回数组或 list 包装结构。
+     */
+    loadList() {
+      getAutoRuleList().then((res) => {
+        if (res.code != 1) return
+        this.apiCards = Array.isArray(res.data) ? res.data : ((res.data && res.data.list) || [])
+      })
+    },
+    /**
+     * 返回每种自动发放场景的业务说明。
+     */
+    typeDescription(type) {
+      const descriptions = {
+        1: '客户首次成为门店新客时自动发放',
+        2: '受邀注册的新客户自动发放',
+        3: '客户超过设定天数未下单时自动召回',
+        4: '每月指定日期向客户定时发放'
+      }
+      return descriptions[type] || ''
+    },
+    /**
+     * 将状态码格式化为可读状态。
+     */
+    statusName(status) {
+      const names = { '-1': '未配置', 0: '已关闭', 1: '已开启' }
+      return names[String(status)] || names[status] || '未配置'
+    },
+    /**
+     * 返回状态颜色类。
+     */
+    statusClass(status) {
+      if (Number(status) === 1) return 'enabled'
+      if (Number(status) === 0) return 'disabled'
+      return 'unconfigured'
+    },
+    /**
+     * 从卡片或第一条红包规则读取摘要字段。
+     */
+    ruleValue(item, field, fallback) {
+      if (item[field] !== undefined && item[field] !== '') return item[field]
+      let rules = item.rules || []
+      if (typeof rules === 'string') {
+        try {
+          rules = JSON.parse(rules)
+        } catch (error) {
+          rules = []
+        }
+      }
+      return rules[0] && rules[0][field] !== undefined ? rules[0][field] : fallback
+    },
+    /**
+     * 接口未返回摘要时,根据范围类型生成兜底文本。
+     */
+    formatScope(item) {
+      const names = { 1: '全部商品适用', 2: '指定花束分类', 3: '指定花束商品' }
+      return names[Number(item.scopeType) || 1]
+    },
+    /**
+     * 进入指定类型的自动红包配置页。
+     */
+    goEdit(type) {
+      uni.navigateTo({ url: `/admin/hb/autoSend/edit?type=${type}` })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.auto-list-page {
+  min-height: 100vh;
+  padding: 24upx;
+  background: #f5f6f7;
+  box-sizing: border-box;
+}
+.rule-card {
+  margin-bottom: 24upx;
+  padding: 30upx;
+  border-radius: 18upx;
+  background: #fff;
+  box-shadow: 0 4upx 18upx rgba(0, 0, 0, 0.04);
+}
+.card-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+.type-name {
+  color: #222;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.type-desc {
+  margin-top: 10upx;
+  color: #999;
+  font-size: 24upx;
+}
+.status {
+  padding: 8upx 18upx;
+  border-radius: 24upx;
+  font-size: 24upx;
+}
+.status.enabled {
+  background: #e8fff3;
+  color: #00b42a;
+}
+.status.disabled {
+  background: #fff4e8;
+  color: #ff7d00;
+}
+.status.unconfigured {
+  background: #f2f3f5;
+  color: #86909c;
+}
+.summary-row {
+  display: flex;
+  margin-top: 28upx;
+  padding: 24upx 0;
+  border-top: 1upx solid #f4f4f4;
+  border-bottom: 1upx solid #f4f4f4;
+}
+.summary-item {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  align-items: center;
+}
+.summary-value {
+  color: #f53f3f;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.summary-label {
+  margin-top: 8upx;
+  color: #999;
+  font-size: 23upx;
+}
+.scope-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 22upx 0;
+}
+.scope-label {
+  color: #666;
+  font-size: 26upx;
+}
+.scope-value {
+  color: #333;
+  font-size: 26upx;
+}
+.config-btn {
+  height: 72upx;
+  line-height: 72upx;
+  border-radius: 36upx;
+  background: #09c567;
+  color: #fff;
+  font-size: 28upx;
+}
+.config-btn::after {
+  border: 0;
+}
+</style>

+ 304 - 0
hdApp/src/admin/hb/components/hbBaseSetting.vue

@@ -0,0 +1,304 @@
+<!--
+  多红包基础设置组件,供手动发放和自动发放页面复用。
+  统一红包字段、日期选择和增删交互,避免不同入口配置规则不一致。
+-->
+<template>
+  <view class="base-setting">
+    <view class="section-header">
+      <text class="section-title">基础设置</text>
+      <button class="add-btn" @click="addRule">添加新红包</button>
+    </view>
+
+    <view v-for="(item, index) in innerRules" :key="index" class="rule-card">
+      <view class="card-title">
+        <text>红包 {{ index + 1 }}</text>
+        <text v-if="index > 0" class="delete-text" @click="deleteRule(index)">删除</text>
+      </view>
+      <view class="form-row">
+        <text class="label">红包金额</text>
+        <input class="input" type="digit" :value="item.hbAmount" placeholder="请输入金额" @input="updateField(index, 'hbAmount', $event)" />
+      </view>
+      <view class="form-row">
+        <text class="label">最低消费</text>
+        <input class="input" type="digit" :value="item.miniCost" placeholder="请输入金额(0表示无门槛)" @input="updateField(index, 'miniCost', $event)" />
+      </view>
+      <view class="form-row">
+        <text class="label">发放数量</text>
+        <input class="input" type="number" :value="item.hbNum" placeholder="请输入数量" @input="updateField(index, 'hbNum', $event)" />
+      </view>
+      <view class="form-row">
+        <text class="label">有效时长</text>
+        <input class="input" type="number" :value="item.duration" placeholder="请输入天数(0表示永久)" @input="updateField(index, 'duration', $event)" />
+      </view>
+      <view class="form-row effective-row">
+        <text class="label">生效时间</text>
+        <view class="type-selector">
+          <view class="type-item" :class="{ active: isEffectiveType(item, 1) }" @click="changeEffectiveType(index, 1)">指定日期</view>
+          <view class="type-item" :class="{ active: isEffectiveType(item, 2) }" @click="changeEffectiveType(index, 2)">指定天数</view>
+        </view>
+      </view>
+      <view v-if="isEffectiveType(item, 1)" class="form-row" @click="openDatePicker(index)">
+        <text class="label">选择日期</text>
+        <text class="select-value" :class="{ placeholder: !item.effectiveDate }">{{ item.effectiveDate || '马上生效(点击可指定日期)' }}</text>
+      </view>
+      <view v-else class="form-row">
+        <text class="label">延后生效</text>
+        <input class="input" type="number" :value="item.effectiveDays" placeholder="0" @input="updateField(index, 'effectiveDays', $event)" />
+        <text class="unit">天后</text>
+      </view>
+    </view>
+
+    <view v-if="datePickerShow" class="date-picker-mask" @click="datePickerShow = false"></view>
+    <mx-date-picker
+      :show="datePickerShow"
+      format="yyyy-mm-dd"
+      type="date"
+      :value="datePickerValue"
+      :show-tips="true"
+      @confirm="confirmDatePicker"
+      @cancel="datePickerShow = false"
+    />
+  </view>
+</template>
+
+<script>
+import MxDatePicker from '@/components/mx-datepicker/mx-datepicker.vue'
+
+/**
+ * 多红包基础设置组件。
+ * value/rules 均表示红包规则数组,修改时通过 input 返回完整新数组,不直接改写父组件数据。
+ */
+export default {
+  name: 'HbBaseSetting',
+  components: { MxDatePicker },
+  props: {
+    value: {
+      type: Array,
+      default: () => []
+    },
+    rules: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      innerRules: [],
+      datePickerShow: false,
+      datePickerValue: '',
+      currentRuleIndex: -1
+    }
+  },
+  watch: {
+    value: {
+      immediate: true,
+      deep: true,
+      handler() {
+        this.syncRules()
+      }
+    },
+    rules: {
+      immediate: true,
+      deep: true,
+      handler() {
+        this.syncRules()
+      }
+    }
+  },
+  methods: {
+    /**
+     * 创建默认规则,保证新旧接口字段完整且生效时间默认马上生效。
+     */
+    createDefaultRule() {
+      return {
+        name: '',
+        hbAmount: '',
+        hbNum: 1,
+        miniCost: '',
+        duration: '',
+        effectiveType: 1,
+        effectiveDate: '',
+        effectiveDays: 0
+      }
+    },
+    /**
+     * 将父级规则复制到组件内部,防止子组件直接修改 props。
+     */
+    syncRules() {
+      const source = this.value.length ? this.value : this.rules
+      const list = source.length ? source : [this.createDefaultRule()]
+      this.innerRules = list.map((item) => Object.assign({}, this.createDefaultRule(), item))
+    },
+    /**
+     * 通知父级规则变化;返回副本便于 v-model 与 :rules + @input 两种方式使用。
+     */
+    emitChange() {
+      this.$emit('input', this.innerRules.map((item) => Object.assign({}, item)))
+    },
+    /**
+     * 判断规则当前生效类型,避免在小程序模板中执行数值转换。
+     */
+    isEffectiveType(item, type) {
+      return Number(item.effectiveType) === type
+    },
+    /**
+     * 更新指定规则字段,输入事件值在此统一提取。
+     */
+    updateField(index, field, event) {
+      this.$set(this.innerRules[index], field, event.detail.value)
+      this.emitChange()
+    },
+    /**
+     * 增加一条完整默认红包规则。
+     */
+    addRule() {
+      this.innerRules.push(this.createDefaultRule())
+      this.emitChange()
+    },
+    /**
+     * 删除非首条规则,首条始终保留以避免空配置。
+     */
+    deleteRule(index) {
+      if (index <= 0) return
+      this.innerRules.splice(index, 1)
+      this.emitChange()
+    },
+    /**
+     * 切换生效方式;指定日期允许空值代表马上生效。
+     */
+    changeEffectiveType(index, type) {
+      this.$set(this.innerRules[index], 'effectiveType', type)
+      this.emitChange()
+    },
+    /**
+     * 打开当前规则的日期选择器。
+     */
+    openDatePicker(index) {
+      this.currentRuleIndex = index
+      this.datePickerValue = this.innerRules[index].effectiveDate || ''
+      this.datePickerShow = true
+    },
+    /**
+     * 回填日期选择结果并同步父级。
+     */
+    confirmDatePicker(event) {
+      if (this.currentRuleIndex < 0) return
+      this.$set(this.innerRules[this.currentRuleIndex], 'effectiveDate', event.value)
+      this.datePickerShow = false
+      this.emitChange()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.base-setting {
+  margin-bottom: 24upx;
+}
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20upx;
+}
+.section-title {
+  padding-left: 16upx;
+  border-left: 8upx solid #09c567;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.add-btn {
+  height: 58upx;
+  line-height: 58upx;
+  padding: 0 24upx;
+  margin: 0;
+  border-radius: 30upx;
+  background: #09c567;
+  color: #fff;
+  font-size: 25upx;
+}
+.add-btn::after {
+  border: 0;
+}
+.rule-card {
+  margin-bottom: 20upx;
+  overflow: hidden;
+  border-radius: 16upx;
+  background: #fff;
+}
+.card-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24upx 30upx;
+  border-bottom: 1upx solid #f0f0f0;
+  color: #333;
+  font-size: 30upx;
+  font-weight: bold;
+}
+.delete-text {
+  color: #f53f3f;
+  font-size: 26upx;
+  font-weight: normal;
+}
+.form-row {
+  display: flex;
+  align-items: center;
+  min-height: 92upx;
+  padding: 0 30upx;
+  border-bottom: 1upx solid #f5f5f5;
+}
+.label {
+  width: 180upx;
+  color: #333;
+  font-size: 28upx;
+}
+.input {
+  flex: 1;
+  text-align: right;
+  color: #333;
+  font-size: 28upx;
+}
+.unit {
+  margin-left: 8upx;
+  color: #666;
+  font-size: 26upx;
+}
+.type-selector {
+  display: flex;
+  flex: 1;
+  padding: 4upx;
+  border-radius: 8upx;
+  background: #f5f5f5;
+}
+.type-item {
+  flex: 1;
+  padding: 12upx 0;
+  border-radius: 6upx;
+  color: #666;
+  text-align: center;
+  font-size: 26upx;
+}
+.type-item.active {
+  background: #fff;
+  color: #09c567;
+}
+.select-value {
+  flex: 1;
+  color: #333;
+  text-align: right;
+  font-size: 27upx;
+}
+.select-value.placeholder {
+  color: #999;
+}
+.date-picker-mask {
+  position: fixed;
+  z-index: 98;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background: rgba(0, 0, 0, 0.45);
+}
+</style>

+ 304 - 0
hdApp/src/admin/hb/components/hbScopePicker.vue

@@ -0,0 +1,304 @@
+<!--
+  红包适用范围组件,供手动和自动红包配置页面复用。
+  统一全部商品、花束分类、花束商品及活动特价适用规则,减少提交参数差异。
+-->
+<template>
+  <view class="scope-picker">
+    <view class="section-title">适用范围</view>
+    <view class="scope-card">
+      <view
+        v-for="option in scopeOptions"
+        :key="option.value"
+        class="scope-option"
+        @click="changeScopeType(option.value)"
+      >
+        <view class="radio-dot" :class="{ active: localScopeType === option.value }"></view>
+        <text>{{ option.label }}</text>
+      </view>
+
+      <view v-if="localScopeType === 2" class="category-panel">
+        <view
+          v-for="category in categoryList"
+          :key="category.id"
+          class="checkbox-item"
+          @click.stop="toggleCategory(category.id)"
+        >
+          <view class="checkbox-box" :class="{ checked: isCategoryChecked(category.id) }">
+            <text v-if="isCategoryChecked(category.id)">✓</text>
+          </view>
+          <text class="checkbox-name">{{ category.categoryName || category.name }}</text>
+        </view>
+        <view v-if="!categoryList.length" class="empty-text">暂无花束分类</view>
+      </view>
+
+      <view v-if="localScopeType === 3" class="goods-select" @click="openGoodsSelect">
+        <text>{{ selectedGoodsText }}</text>
+        <text class="arrow">›</text>
+      </view>
+
+      <view class="switch-row">
+        <view>
+          <view class="switch-title">特价及活动商品是否适用</view>
+          <view class="switch-tip">关闭后,促销商品不可使用该红包</view>
+        </view>
+        <switch color="#09c567" :checked="localSpecialApplicable === 1" @change="changeSpecialApplicable" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getClass } from '@/api/category'
+
+/**
+ * 红包适用范围选择组件。
+ * 接收范围类型、范围值及活动商品开关,每次变化 emit change 返回完整配置对象。
+ */
+export default {
+  name: 'HbScopePicker',
+  props: {
+    scopeType: {
+      type: [Number, String],
+      default: 1
+    },
+    scopeValue: {
+      type: [String, Array],
+      default: ''
+    },
+    specialApplicable: {
+      type: [Number, String, Boolean],
+      default: 1
+    }
+  },
+  data() {
+    return {
+      scopeOptions: [
+        { label: '全部商品适用', value: 1 },
+        { label: '指定花束分类', value: 2 },
+        { label: '指定花束商品', value: 3 }
+      ],
+      categoryList: [],
+      localScopeType: 1,
+      localScopeValue: '',
+      localSpecialApplicable: 1,
+      selectedGoodsCount: 0
+    }
+  },
+  computed: {
+    selectedGoodsText() {
+      const count = this.selectedGoodsCount || this.parseIds(this.localScopeValue).length
+      return count ? `已选择${count}个花束商品` : '请选择花束商品'
+    }
+  },
+  watch: {
+    scopeType: {
+      immediate: true,
+      handler(value) {
+        this.localScopeType = Number(value) || 1
+      }
+    },
+    scopeValue: {
+      immediate: true,
+      handler(value) {
+        this.localScopeValue = Array.isArray(value) ? value.join(',') : (value || '')
+      }
+    },
+    specialApplicable: {
+      immediate: true,
+      handler(value) {
+        this.localSpecialApplicable = Number(value) ? 1 : 0
+      }
+    }
+  },
+  created() {
+    uni.$on('categoryAdGoodsSelected', this.onGoodsSelected)
+    this.loadCategories()
+  },
+  beforeDestroy() {
+    uni.$off('categoryAdGoodsSelected', this.onGoodsSelected)
+  },
+  methods: {
+    /**
+     * 拉取分类供页内多选;接口异常时保留空状态,避免阻断其他范围选择。
+     */
+    loadCategories() {
+      getClass().then((res) => {
+        if (res.code == 1) this.categoryList = res.data || []
+      })
+    },
+    /**
+     * 将逗号分隔值或数组统一转为字符串 ID 数组。
+     */
+    parseIds(value) {
+      if (Array.isArray(value)) return value.map(String)
+      return value ? String(value).split(',').filter(Boolean) : []
+    },
+    /**
+     * 切换范围类型时清空旧类型范围值,避免错误复用分类或商品 ID。
+     */
+    changeScopeType(type) {
+      if (this.localScopeType !== type) {
+        this.localScopeType = type
+        this.localScopeValue = ''
+        this.selectedGoodsCount = 0
+      }
+      this.emitChange()
+    },
+    /**
+     * 判断分类是否已选择,用于渲染页内复选框。
+     */
+    isCategoryChecked(id) {
+      return this.parseIds(this.localScopeValue).indexOf(String(id)) > -1
+    },
+    /**
+     * 增删指定分类并同步完整范围配置。
+     */
+    toggleCategory(id) {
+      const key = String(id)
+      const ids = this.parseIds(this.localScopeValue)
+      const index = ids.indexOf(key)
+      if (index > -1) ids.splice(index, 1)
+      else ids.push(key)
+      this.localScopeValue = ids.join(',')
+      this.emitChange()
+    },
+    /**
+     * 跳转商品多选页,携带当前选择以支持再次编辑。
+     */
+    openGoodsSelect() {
+      uni.navigateTo({
+        url: `/admin/goods/ad-goods-select?slotIndex=0&mode=multi&selectedIds=${this.localScopeValue || ''}`
+      })
+    },
+    /**
+     * 接收商品多选页广播,只处理本组件约定的 slotIndex=0。
+     */
+    onGoodsSelected(payload) {
+      if (!payload || Number(payload.slotIndex || 0) !== 0) return
+      const ids = (payload.goodsIds || []).map(String)
+      this.localScopeValue = ids.join(',')
+      this.selectedGoodsCount = ids.length
+      this.emitChange()
+    },
+    /**
+     * 更新活动特价商品开关。
+     */
+    changeSpecialApplicable(event) {
+      this.localSpecialApplicable = event.detail.value ? 1 : 0
+      this.emitChange()
+    },
+    /**
+     * 将组件当前完整状态通知父页面,保证提交字段始终成组更新。
+     */
+    emitChange() {
+      this.$emit('change', {
+        scopeType: this.localScopeType,
+        scopeValue: this.localScopeValue,
+        specialApplicable: this.localSpecialApplicable
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.scope-picker {
+  margin-bottom: 24upx;
+}
+.section-title {
+  margin-bottom: 20upx;
+  padding-left: 16upx;
+  border-left: 8upx solid #09c567;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.scope-card {
+  overflow: hidden;
+  border-radius: 16upx;
+  background: #fff;
+}
+.scope-option {
+  display: flex;
+  align-items: center;
+  min-height: 88upx;
+  padding: 0 30upx;
+  border-bottom: 1upx solid #f5f5f5;
+  color: #333;
+  font-size: 29upx;
+}
+.radio-dot {
+  width: 30upx;
+  height: 30upx;
+  margin-right: 18upx;
+  border: 2upx solid #bbb;
+  border-radius: 50%;
+  box-sizing: border-box;
+}
+.radio-dot.active {
+  border: 9upx solid #09c567;
+}
+.category-panel {
+  padding: 10upx 30upx 24upx 78upx;
+  border-bottom: 1upx solid #f5f5f5;
+}
+.checkbox-item {
+  display: flex;
+  align-items: center;
+  margin-top: 20upx;
+}
+.checkbox-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32upx;
+  height: 32upx;
+  margin-right: 16upx;
+  border: 2upx solid #bbb;
+  border-radius: 5upx;
+  color: #fff;
+  font-size: 24upx;
+  box-sizing: border-box;
+}
+.checkbox-box.checked {
+  border-color: #09c567;
+  background: #09c567;
+}
+.checkbox-name {
+  color: #555;
+  font-size: 27upx;
+}
+.empty-text {
+  padding: 24upx 0;
+  color: #999;
+  font-size: 26upx;
+}
+.goods-select {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 82upx;
+  padding: 0 30upx 0 78upx;
+  border-bottom: 1upx solid #f5f5f5;
+  color: #666;
+  font-size: 27upx;
+}
+.arrow {
+  color: #aaa;
+  font-size: 42upx;
+}
+.switch-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 26upx 30upx;
+}
+.switch-title {
+  color: #333;
+  font-size: 29upx;
+}
+.switch-tip {
+  margin-top: 8upx;
+  color: #999;
+  font-size: 23upx;
+}
+</style>

+ 536 - 232
hdApp/src/admin/hb/list.vue

@@ -1,59 +1,99 @@
+<!--
+  红包明细列表,供门店管理员搜索客户、筛选时间/类型/状态并管理红包。
+  支持从统计页携带条件进入,也提供手动发放和自动发放快捷入口。
+-->
 <template>
-  <view class="app-main app-content">
-    <!-- 顶部固定区域 -->
+  <view class="list-page">
     <view class="fixed-header">
-      <view class="input-wrap_box">
+      <view class="search-row">
         <view class="search-container">
-          <AppSearchModule ref="searchRef" placeholder="输入客户名搜索" v-model="searchText" @input="searchFn" />
-          <view v-if="customId != 0" class="search-clear-overlay" @click.stop="clearFn">清空</view>
+          <app-search-module ref="searchRef" placeholder="输入客户名搜索" v-model="searchText" @input="searchFn" />
+          <view v-if="customId" class="search-clear" @click.stop="clearCustomer">清空</view>
         </view>
-        <button class="admin-button-com middle blue" @click="sendRedPacket">发红包</button>
+        <button class="header-btn filter-btn" @click="openFilter">筛选</button>
+        <button class="header-btn auto-btn" @click="goAutoSend">自动发红包</button>
+        <button class="header-btn send-btn" @click="sendRedPacket">发红包</button>
       </view>
 
-      <!-- 搜索结果下拉 -->
-      <view class="search-result-panel" v-if="showSearch">
-        <block v-if="searchList.length > 0">
-          <view class="search-item" v-for="(item, index) in searchList" :key="index" @click="getCustom(item)">
-            <view class="name">{{ item.name }}</view>
-            <view class="info">{{ item.phone }}</view>
-          </view>
-        </block>
-        <block v-else>
-          <view class="no-result">无搜索结果</view>
-        </block>
+      <view v-if="showSearch" class="search-result-panel">
+        <view v-for="item in searchList" :key="item.id" class="search-item" @click="selectCustomer(item)">
+          <view class="name">{{ item.name }}</view>
+          <view class="phone">{{ item.phone }}</view>
+        </view>
+        <view v-if="!searchList.length" class="no-result">无搜索结果</view>
       </view>
 
-      <app-tabs :tabs="tabs" class="app-tabs" :currentTab="tabIndex" :height="88" @change="change" itemWidth="25%" />
+      <app-tabs :tabs="tabs" :currentTab="tabIndex" :height="88" itemWidth="25%" @change="changeTab" />
     </view>
 
-    <!-- 列表区域 -->
     <view class="list-wrap">
-      <block v-if="list.data.length > 0">
-        <view class="hb-card" v-for="(item, index) in list.data" :key="index">
-          <view class="card-header">
-            <view class="user-info">
-              <text class="name">{{ item.customName }}</text>
-              <text class="amount">¥ {{ item.amount?parseFloat(item.amount):0 }}</text>
-            </view>
-            <view class="status" :class="{ 'status-wait': item.status == 0, 'status-used': item.status == 1, 'status-expired': item.status == -1 }">
-              {{ item.status==0?'待使用':item.status==1?'已使用':item.status==-1?'已失效':'未知'}}
-            </view>
+      <view v-for="item in list.data" :key="item.id" class="hb-card">
+        <view class="card-header">
+          <view>
+            <text class="customer-name">{{ item.customName }}</text>
+            <text class="amount">¥{{ displayAmount(item.amount) }}</text>
           </view>
-          <view class="card-body">
-            <view class="row">开始时间:{{ formatDateTime(item.beginTime) }}</view>
-            <view class="row">到期时间:<text v-if="item.endTime == 4102416000">永久有效</text><text v-else>{{ formatDateTime(item.endTime) }}</text></view>
-            <view class="row">有效时长:<text v-if="item.duration == 0">永久</text><text v-else>{{ item.duration }}天</text></view>
-            <view class="row" v-if="Number(item.minConsume)==0">最低消费:无门槛</view>
-            <view class="row" v-else>最低消费:{{ item.minConsume?parseFloat(item.minConsume):0 }}元</view>
-            <view class="row">获得方式:{{ formatGetType(item.getType) }}</view>
-            <view class="row">获得时间:{{ item.createdAt.substr(0,16) }}</view>
+          <!-- 微信小程序 :class 不支持方法调用,改用对象语法 -->
+          <text
+            class="status"
+            :class="{
+              'status-used': item.status == 1,
+              'status-expired': item.status == -1,
+              'status-wait': item.status != 1 && item.status != -1
+            }"
+          >{{ formatStatus(item.status) }}</text>
+        </view>
+        <view class="card-body">
+          <view class="info-row">红包名称:{{ item.name || '红包' }}</view>
+          <view class="info-row">红包类型:{{ item.getTypeName || formatGetType(item.getType) }}</view>
+          <view v-if="item.scopeText" class="info-row">适用范围:{{ item.scopeText }}</view>
+          <view class="info-row">开始时间:{{ formatDateTime(item.beginTime) }}</view>
+          <view class="info-row">到期时间:<text v-if="isPermanent(item.endTime)">永久有效</text><text v-else>{{ formatDateTime(item.endTime) }}</text></view>
+          <view class="info-row">有效时长:{{ formatDuration(item.duration) }}</view>
+          <view class="info-row">最低消费:{{ formatMinConsume(item.minConsume) }}</view>
+          <view class="info-row">获得时间:{{ formatCreatedAt(item.createdAt) }}</view>
+        </view>
+        <view v-if="isUnused(item.status)" class="card-footer">
+          <button class="void-btn" @click="voidItem(item)">作废</button>
+        </view>
+      </view>
+      <app-wrapper-empty v-if="!list.data.length" title="暂无数据" />
+    </view>
+
+    <view v-if="filterShow" class="overlay" @click="filterShow = false">
+      <view class="filter-panel" @click.stop>
+        <view class="panel-title">筛选</view>
+        <view class="filter-section">
+          <view class="filter-label">时间范围</view>
+          <view class="date-row">
+            <picker mode="date" :value="draftFilter.beginDate" @change="changeDraftBeginDate">
+              <view class="date-value">{{ draftFilter.beginDate || '开始日期' }}</view>
+            </picker>
+            <text class="date-line">至</text>
+            <picker mode="date" :value="draftFilter.endDate" @change="changeDraftEndDate">
+              <view class="date-value">{{ draftFilter.endDate || '结束日期' }}</view>
+            </picker>
           </view>
-          <view class="card-footer" v-if="item.status === '0'">
-            <button class="action-btn" @click="voidItem(item)">作废</button>
+        </view>
+        <view class="filter-section">
+          <view class="filter-label">红包类型</view>
+          <view class="option-wrap">
+            <view
+              v-for="option in typeOptions"
+              :key="option.key"
+              class="option-item"
+              :class="{ active: isDraftType(option.value) }"
+              @click="draftFilter.getType = option.value"
+            >
+              {{ option.label }}
+            </view>
           </view>
         </view>
-      </block>
-      <app-wrapper-empty v-else title="暂无数据" />
+        <view class="panel-actions">
+          <button class="reset-btn" @click="resetFilter">重置</button>
+          <button class="confirm-btn" @click="confirmFilter">确定</button>
+        </view>
+      </view>
     </view>
   </view>
 </template>
@@ -62,10 +102,14 @@
 import AppTabs from '@/components/plugin/tabs'
 import AppSearchModule from '@/components/module/app-search'
 import AppWrapperEmpty from '@/components/app-wrapper-empty'
-import {getList} from '@/api/member'
+import { getList } from '@/api/member'
 import { hbList, unAvailableHb } from '@/api/hb'
-import { list } from "@/mixins"
+import { list } from '@/mixins'
 
+/**
+ * 红包列表页。
+ * 统一标签和弹窗筛选状态,查询接口始终携带日期、类型、状态及客户条件。
+ */
 export default {
   mixins: [list],
   components: {
@@ -77,11 +121,33 @@ export default {
     return {
       tabIndex: 0,
       tabs: [
-        {name: '全部', value: 0,status:''},
-        {name: '待使用', value: 0,status:'0'},
-        {name: '已使用', value: 0,status:'1'},
-        {name: '已失效', value: 0,status:'-1'}
+        { name: '全部', value: 0, status: '' },
+        { name: '待使用', value: 0, status: '0' },
+        { name: '已使用', value: 0, status: '1' },
+        { name: '已失效', value: 0, status: '-1' }
       ],
+      typeOptions: [
+        { key: 'all', label: '全部', value: '' },
+        { key: '0', label: '新客', value: 0 },
+        { key: '1', label: '赠送', value: 1 },
+        { key: '2', label: '充值送', value: 2 },
+        { key: '3', label: '受邀新客', value: 3 },
+        { key: '4', label: '沉睡召回', value: 4 },
+        { key: '5', label: '会员日', value: 5 }
+      ],
+      // 使用状态仅由顶部 tabs 控制,筛选弹窗不再重复提供
+      filter: {
+        beginDate: '',
+        endDate: '',
+        getType: '',
+        status: ''
+      },
+      draftFilter: {
+        beginDate: '',
+        endDate: '',
+        getType: ''
+      },
+      filterShow: false,
       searchText: '',
       showSearch: false,
       searchList: [],
@@ -89,274 +155,512 @@ export default {
     }
   },
   onLoad(options) {
+    this.filter.beginDate = options.beginDate || ''
+    this.filter.endDate = options.endDate || ''
+    this.filter.getType = options.getType !== undefined ? options.getType : ''
+    this.filter.status = options.status !== undefined ? options.status : ''
+    this.tabIndex = this.getStatusTabIndex(this.filter.status)
     if (options.customId) {
       this.customId = options.customId
-      this.$refs.searchRef.search = options.customName
+      this.searchText = options.customName || ''
     }
   },
   onShow() {
     this.loadData()
   },
-  onPullDownRefresh () {
-    this.resetList();
-    this.getHbList().then(res => {
+  onPullDownRefresh() {
+    this.loadData().then(() => {
       uni.stopPullDownRefresh()
-    });
+    })
   },
-  onReachBottom () {
-    if (!this.list.finished) {
-      this.getHbList().then(res => {
-        uni.stopPullDownRefresh()
-      });
-    } else {
-      uni.stopPullDownRefresh()
-    }
+  onReachBottom() {
+    if (!this.list.finished) this.getHbList()
   },
   methods: {
-    init() {},
+    /**
+     * 根据状态查询值定位顶部标签,保证统计页下钻状态正确回显。
+     */
+    getStatusTabIndex(status) {
+      const index = this.tabs.findIndex((item) => String(item.status) === String(status))
+      return index > -1 ? index : 0
+    },
+    /**
+     * 重置分页并查询红包列表。
+     */
     loadData() {
       this.resetList()
-      this.getHbList()
+      return this.getHbList()
     },
+    /**
+     * 请求红包明细,日期与类型始终来自已确认筛选条件。
+     */
     getHbList() {
-      let status = this.tabs[this.tabIndex].status
-      return hbList({ status: status, page: this.list.page, limit: this.list.pageSize, customId: this.customId }).then((res) => {
+      const status = this.tabs[this.tabIndex].status
+      return hbList({
+        status,
+        beginDate: this.filter.beginDate,
+        endDate: this.filter.endDate,
+        getType: this.filter.getType,
+        page: this.list.page,
+        limit: this.list.pageSize,
+        customId: this.customId
+      }).then((res) => {
         this.completes(res)
-        for(let i = 0; i < this.tabs.length; i++){
-          if(i == 0){
-            this.tabs[i].value = Number(res.data.all) || 0
-          }else if(i == 1){
-            this.tabs[i].value = Number(res.data.unUse) || 0
-          }else if(i == 2){
-            this.tabs[i].value = Number(res.data.used) || 0
-          }else if(i == 3){
-            this.tabs[i].value = Number(res.data.expired) || 0
-          }
-        }
+        this.tabs[0].value = Number(res.data.all) || 0
+        this.tabs[1].value = Number(res.data.unUse) || 0
+        this.tabs[2].value = Number(res.data.used) || 0
+        this.tabs[3].value = Number(res.data.expired) || 0
       })
     },
-    change(e) {
-      this.tabIndex = e.index
+    /**
+     * 切换顶部状态标签并同步筛选状态。
+     */
+    changeTab(event) {
+      this.tabIndex = event.index
+      this.filter.status = this.tabs[this.tabIndex].status
       this.loadData()
     },
-    searchFn(e) {
-      if (!e) {
+    /**
+     * 按客户名称搜索候选客户,输入为空时关闭候选层。
+     */
+    searchFn(value) {
+      if (!value) {
         this.showSearch = false
         return
       }
-      getList({page: 1, name: e, type: 0}).then((res) => {
-        if (res.data && res.data.list) {
-          this.searchList = res.data.list
-          this.showSearch = true
-        } else {
-          this.searchList = []
-        }
+      getList({ page: 1, name: value, type: 0 }).then((res) => {
+        this.searchList = res.data && res.data.list ? res.data.list : []
+        this.showSearch = true
       })
     },
-    getCustom(item) {
+    /**
+     * 选中搜索客户并按客户 ID 刷新红包列表。
+     */
+    selectCustomer(item) {
       this.searchText = item.name
       this.customId = item.id
       this.showSearch = false
       this.loadData()
     },
-    clearFn() {
+    /**
+     * 清除客户筛选并恢复全部客户。
+     */
+    clearCustomer() {
       this.searchText = ''
       this.customId = 0
-      if (this.$refs.searchRef) {
-        this.$refs.searchRef.search = ''
+      this.showSearch = false
+      if (this.$refs.searchRef) this.$refs.searchRef.search = ''
+      this.loadData()
+    },
+    /**
+     * 判断红包类型筛选项是否选中。
+     */
+    isDraftType(type) {
+      return String(this.draftFilter.getType) === String(type)
+    },
+    /**
+     * 打开筛选面板时只复制时间与类型;使用状态由顶部 tabs 单独维护。
+     */
+    openFilter() {
+      this.draftFilter = {
+        beginDate: this.filter.beginDate,
+        endDate: this.filter.endDate,
+        getType: this.filter.getType
+      }
+      this.filterShow = true
+    },
+    /**
+     * 更新筛选草稿开始日期。
+     */
+    changeDraftBeginDate(event) {
+      this.draftFilter.beginDate = event.detail.value
+    },
+    /**
+     * 更新筛选草稿结束日期。
+     */
+    changeDraftEndDate(event) {
+      this.draftFilter.endDate = event.detail.value
+    },
+    /**
+     * 清空筛选草稿(时间、类型),不影响顶部使用状态标签。
+     */
+    resetFilter() {
+      this.draftFilter = {
+        beginDate: '',
+        endDate: '',
+        getType: ''
       }
+    },
+    /**
+     * 校验并应用时间/类型筛选;使用状态继续沿用当前 tabs。
+     */
+    confirmFilter() {
+      if (this.draftFilter.beginDate && this.draftFilter.endDate && this.draftFilter.beginDate > this.draftFilter.endDate) {
+        this.$msg('开始日期不能晚于结束日期')
+        return
+      }
+      this.filter.beginDate = this.draftFilter.beginDate
+      this.filter.endDate = this.draftFilter.endDate
+      this.filter.getType = this.draftFilter.getType
+      this.filterShow = false
       this.loadData()
     },
+    /**
+     * 直接进入手动发红包页。
+     */
     sendRedPacket() {
-      this.$util.pageTo({url: '/admin/custom/selectCustom?style=5'})
+      uni.navigateTo({ url: '/admin/hb/send' })
     },
+    /**
+     * 进入自动发红包规则列表。
+     */
+    goAutoSend() {
+      uni.navigateTo({ url: '/admin/hb/autoSend/list' })
+    },
+    /**
+     * 作废待使用红包,成功后刷新汇总与列表。
+     */
     voidItem(item) {
-      this.$util.confirmModal({content: '确认作废该红包?'}, () => {
-        unAvailableHb({id: item.id, status: -1}).then(res => {
-          if(res.code == 1){
-            item.status = -1
+      this.$util.confirmModal({ content: '确认作废该红包?' }, () => {
+        unAvailableHb({ id: item.id, status: -1 }).then((res) => {
+          if (res.code == 1) {
             this.$msg('操作成功')
+            this.loadData()
           }
         })
       })
     },
-    formatDateTime(inputTime) {
-      if (!inputTime) return ''
-      let date = new Date(inputTime)
-      // 如果是10位时间戳(秒),需要乘以1000
-      if (/^\d{10}$/.test(inputTime)) {
-        date = new Date(inputTime * 1000)
-      }
-      const y = date.getFullYear()
-      let m = date.getMonth() + 1
-      m = m < 10 ? ('0' + m) : m
-      let d = date.getDate()
-      d = d < 10 ? ('0' + d) : d
-      let h = date.getHours()
-      h = h < 10 ? ('0' + h) : h
-      let minute = date.getMinutes()
-      //let second = date.getSeconds()
-      minute = minute < 10 ? ('0' + minute) : minute
-      //second = second < 10 ? ('0' + second) : second
-      return y + '-' + m + '-' + d + ' ' + h + ':' + minute //+ ':' + second
+    /**
+     * 格式化金额显示,空值统一为 0。
+     */
+    displayAmount(value) {
+      return value ? parseFloat(value) : 0
+    },
+    /**
+     * 判断红包是否使用永久有效的约定时间戳。
+     */
+    isPermanent(endTime) {
+      return Number(endTime) === 4102416000
+    },
+    /**
+     * 格式化有效时长。
+     */
+    formatDuration(duration) {
+      return Number(duration) === 0 ? '永久' : `${duration}天`
     },
+    /**
+     * 格式化最低消费金额。
+     */
+    formatMinConsume(value) {
+      return Number(value) === 0 ? '无门槛' : `${parseFloat(value)}元`
+    },
+    /**
+     * 判断红包是否处于待使用状态。
+     */
+    isUnused(status) {
+      return Number(status) === 0
+    },
+    /**
+     * 格式化红包状态文本。
+     */
+    formatStatus(status) {
+      const names = { 0: '待使用', 1: '已使用', '-1': '已失效' }
+      return names[status] || '未知'
+    },
+    /**
+     * 格式化新版六类红包来源名称。
+     */
     formatGetType(type) {
-      const typeMap = {
-        0: '新人领取',
-        1: '门店发放',
-        2: '充值赠送'
+      const names = {
+        0: '新客红包',
+        1: '赠送(门店赠送或手动发)红包',
+        2: '充值送红包',
+        3: '受邀新客红包',
+        4: '沉睡召回红包',
+        5: '会员日红包'
       }
-      return typeMap[type] || '未知方式'
+      return names[type] || '未知方式'
+    },
+    /**
+     * 将秒级时间戳、毫秒时间戳或日期字符串统一格式化到分钟。
+     */
+    formatDateTime(inputTime) {
+      if (!inputTime) return ''
+      const value = /^\d{10}$/.test(String(inputTime)) ? Number(inputTime) * 1000 : inputTime
+      const date = new Date(value)
+      const pad = (number) => number < 10 ? `0${number}` : number
+      return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
+    },
+    /**
+     * 截取接口创建时间,空值时安全返回。
+     */
+    formatCreatedAt(value) {
+      return value ? String(value).substr(0, 16) : ''
     }
   }
 }
 </script>
 
 <style lang="scss" scoped>
-.app-content {
-  background-color: #f5f5f5;
+.list-page {
   min-height: 100vh;
+  background: #f5f6f7;
 }
 .fixed-header {
   position: fixed;
+  z-index: 100;
   top: 0;
   left: 0;
   width: 100%;
-  z-index: 100;
-  background-color: #fff;
-  margin-bottom: 12upx;
+  background: #fff;
 }
-.input-wrap_box {
+.search-row {
   display: flex;
   align-items: center;
-  padding: 20upx 30upx;
-  gap: 20upx;
-  .search-container {
-    flex: 1;
-    position: relative;
-  }
+  padding: 18upx 20upx;
+}
+.search-container {
+  position: relative;
+  flex: 1;
+  min-width: 180upx;
 }
-.search-clear-overlay {
+.search-clear {
   position: absolute;
-  right: 20upx;
+  z-index: 3;
   top: 50%;
+  right: 12upx;
+  padding: 7upx 12upx;
   transform: translateY(-50%);
-  z-index: 10;
+  border: 1upx solid #09c567;
+  border-radius: 20upx;
+  background: #fff;
   color: #09c567;
-  padding: 10upx 20upx;
-  font-size: 24upx;
+  font-size: 21upx;
+}
+.header-btn {
+  height: 64upx;
+  line-height: 64upx;
+  margin: 0 0 0 10upx;
+  padding: 0 14upx;
+  border-radius: 8upx;
+  font-size: 23upx;
+}
+.header-btn::after {
+  border: 0;
+}
+.filter-btn {
   border: 1upx solid #09c567;
-  border-radius: 24upx;
-  background-color: rgba(255, 255, 255, 0.9);
+  background: #fff;
+  color: #09c567;
+}
+.auto-btn {
+  background: #0081ff;
+  color: #fff;
+}
+.send-btn {
+  background: #09c567;
+  color: #fff;
 }
 .search-result-panel {
   position: absolute;
-  top: 100upx;
-  left: 30upx;
-  right: 30upx;
-  background: #fff;
-  box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.1);
   z-index: 101;
+  top: 92upx;
+  right: 20upx;
+  left: 20upx;
   max-height: 500upx;
   overflow-y: auto;
-  border-radius: 8upx;
-  .search-item {
-    padding: 20upx;
-    border-bottom: 1upx solid #eee;
-    .name {
-      font-size: 30upx;
-      color: #333;
-    }
-    .info {
-      font-size: 24upx;
-      color: #999;
-    }
-  }
-  .no-result {
-    padding: 30upx;
-    text-align: center;
-    color: #999;
-  }
+  border-radius: 10upx;
+  background: #fff;
+  box-shadow: 0 5upx 20upx rgba(0, 0, 0, 0.15);
+}
+.search-item {
+  padding: 20upx;
+  border-bottom: 1upx solid #eee;
+}
+.name {
+  color: #333;
+  font-size: 29upx;
+}
+.phone {
+  margin-top: 6upx;
+  color: #999;
+  font-size: 24upx;
+}
+.no-result {
+  padding: 35upx;
+  color: #999;
+  text-align: center;
 }
 .list-wrap {
-  margin-top: 20upx;
   padding-top: 190upx;
   padding-bottom: 20upx;
 }
 .hb-card {
-  margin: 20upx 30upx;
+  margin: 20upx 24upx;
+  padding: 28upx;
+  border-radius: 14upx;
   background: #fff;
-  border-radius: 12upx;
-  padding: 30upx;
-  .card-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    border-bottom: 1upx solid #f5f5f5;
-    padding-bottom: 20upx;
-    margin-bottom: 20upx;
-    .user-info {
-      .name {
-        font-size: 32upx;
-        font-weight: bold;
-        margin-right: 20upx;
-        color: #333;
-      }
-      .amount {
-        font-size: 32upx;
-        color: #333;
-        font-weight: bold;
-      }
-    }
-    .status {
-      font-size: 28upx;
-      &.status-wait {
-        color: #333;
-      }
-      &.status-used {
-        color: #666;
-      }
-      &.status-expired {
-        color: red;
-      }
-    }
-  }
-  .card-body {
-    .row {
-      font-size: 28upx;
-      color: #666;
-      margin-bottom: 10upx;
-      line-height: 1.5;
-    }
-  }
-  .card-footer {
-    display: flex;
-    justify-content: flex-end;
-    padding-top: 20upx;
-    border-top: 1upx solid #f5f5f5;
-    margin-top: 20upx;
-    .action-btn {
-      background: #09c567;
-      color: #fff;
-      font-size: 28upx;
-      padding: 10upx 30upx;
-      border-radius: 8upx;
-      line-height: 1.5;
-      margin: 0;
-    }
-  }
 }
-.admin-button-com {
-  padding: 0 30upx;
-  height: 70upx;
-  line-height: 70upx;
-  font-size: 28upx;
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: 20upx;
+  border-bottom: 1upx solid #f3f3f3;
+}
+.customer-name {
+  margin-right: 18upx;
+  color: #222;
+  font-size: 31upx;
+  font-weight: bold;
+}
+.amount {
+  color: #f53f3f;
+  font-size: 32upx;
+  font-weight: bold;
+}
+.status {
+  font-size: 27upx;
+}
+.status-wait {
+  color: #ff7d00;
+}
+.status-used {
+  color: #00b42a;
+}
+.status-expired {
+  color: #86909c;
+}
+.card-body {
+  padding-top: 18upx;
+}
+.info-row {
+  margin-top: 10upx;
+  color: #666;
+  font-size: 27upx;
+  line-height: 1.5;
+}
+.card-footer {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 20upx;
+  padding-top: 18upx;
+  border-top: 1upx solid #f3f3f3;
+}
+.void-btn {
+  height: 58upx;
+  line-height: 58upx;
+  margin: 0;
+  padding: 0 28upx;
+  background: #f53f3f;
+  color: #fff;
+  font-size: 25upx;
+}
+.void-btn::after {
+  border: 0;
+}
+.overlay {
+  position: fixed;
+  z-index: 999;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background: rgba(0, 0, 0, 0.45);
+}
+.filter-panel {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: 650upx;
+  padding: 35upx 28upx 120upx;
+  overflow-y: auto;
+  background: #fff;
+  box-sizing: border-box;
+}
+.panel-title {
+  color: #222;
+  font-size: 34upx;
+  font-weight: bold;
+}
+.filter-section {
+  margin-top: 36upx;
+}
+.filter-label {
+  margin-bottom: 18upx;
+  color: #333;
+  font-size: 29upx;
+}
+.date-row {
+  display: flex;
+  align-items: center;
+}
+.date-row picker {
+  flex: 1;
+}
+.date-value {
+  padding: 18upx 8upx;
   border-radius: 8upx;
-  background-color: #09c567;
+  background: #f5f5f5;
+  color: #666;
+  text-align: center;
+  font-size: 25upx;
+}
+.date-line {
+  margin: 0 12upx;
+  color: #999;
+}
+.option-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.option-item {
+  width: 30%;
+  margin-right: 3.33%;
+  margin-bottom: 16upx;
+  padding: 16upx 6upx;
+  border: 1upx solid transparent;
+  border-radius: 8upx;
+  background: #f5f5f5;
+  color: #666;
+  text-align: center;
+  font-size: 24upx;
+  box-sizing: border-box;
+}
+.option-item.active {
+  border-color: #09c567;
+  background: #e8fff3;
+  color: #09c567;
+}
+.panel-actions {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  display: flex;
+  padding: 20upx 28upx;
+  background: #fff;
+}
+.reset-btn,
+.confirm-btn {
+  width: 48%;
+  height: 76upx;
+  line-height: 76upx;
+  font-size: 29upx;
+}
+.reset-btn {
+  margin-right: 4%;
+  border: 1upx solid #ddd;
+  background: #fff;
+  color: #666;
+}
+.confirm-btn {
+  background: #09c567;
   color: #fff;
-  &.middle {
-    font-size: 28upx;
-  }
+}
+.reset-btn::after,
+.confirm-btn::after {
+  border: 0;
 }
 </style>
-

+ 278 - 149
hdApp/src/admin/hb/send.vue

@@ -1,194 +1,323 @@
+<!--
+  手动发放红包页面,供门店管理员批量选择客户并配置多个红包。
+  将基础设置、适用范围和发送对象分区,解决旧页面仅支持单客户单红包的问题。
+-->
 <template>
-  <view class="app-main app-content">
-    <view class="form-item">
-      <view class="label">客户名称</view>
-      <view class="value">{{ customName }}</view>
-    </view>
-    <view class="form-item">
-      <view class="label">红包个数</view>
-      <input class="input" type="number" @focus="form.count=''" v-model="form.count" placeholder="请输入数量" />
-    </view>
-    <view class="form-item">
-      <view class="label">红包金额</view>
-      <input class="input" type="digit" @focus="form.amount=''" v-model="form.amount" placeholder="请输入金额" />
-    </view>
-    <view class="form-item">
-      <view class="label">最低消费</view>
-      <input class="input" type="digit" @focus="form.minConsume=''" v-model="form.minConsume" placeholder="请输入金额" />
-    </view>
-    <view class="form-item">
-      <view class="label">有效时长</view>
-      <input class="input" type="number" @focus="form.days=''" v-model="form.days" placeholder="请输入天数,填0永久有效" />
-    </view>
-    <view class="form-item">
-      <view class="label">备注信息</view>
-      <input class="input" type="text" v-model="form.remark" placeholder="选填" />
-    </view>
-    <view class="form-item action-row">
-      <view class="label">生效时间</view>
-      <view class="value" @click="openDatePicker">
-        <text v-if="!form.effectiveDate">马上生效(点击可改生效时间)</text>
-        <text v-else>{{form.effectiveDate}}</text>
+  <view class="send-page">
+    <hb-base-setting v-model="rules" />
+
+    <hb-scope-picker
+      :scope-type="scopeType"
+      :scope-value="scopeValue"
+      :special-applicable="specialApplicable"
+      @change="onScopeChange"
+    />
+
+    <view class="section-title">发送设置</view>
+    <view class="send-card">
+      <view class="customer-row" @click="selectCustomers">
+        <text class="label">选择客户</text>
+        <text class="value">{{ customerText }} ›</text>
+      </view>
+      <view v-if="customs.length" class="customer-tags">
+        <view v-for="item in customs" :key="item.customId" class="customer-tag">
+          <text>{{ item.name }}</text>
+          <text class="remove" @click.stop="removeCustomer(item.customId)">×</text>
+        </view>
+      </view>
+      <view class="remark-row">
+        <text class="label">备注信息</text>
+        <input class="remark-input" type="text" v-model="remark" placeholder="选填" />
       </view>
     </view>
-    <view class="footer-btn">
+
+    <view class="footer-placeholder"></view>
+    <view class="footer-bar">
       <button class="cancel-btn" @click="cancel">取消</button>
-      <button class="confirm-btn" @click="confirm">发放</button>
+      <button class="submit-btn" :disabled="submitting" @click="submit">确认发放</button>
     </view>
-
-    <view v-if="datePickerShow" class="date-picker-mask" @click="datePickerShow = false"></view>
-    <mx-date-picker :show="datePickerShow" format="yyyy-mm-dd" type="date" :value="datePickerValue" :show-tips="true" @confirm="confirmDatePicker" @cancel="datePickerShow = false" />
   </view>
 </template>
 
 <script>
-import { createHb } from '@/api/hb';
-import MxDatePicker from '@/components/mx-datepicker/mx-datepicker.vue';
+import { createHb } from '@/api/hb'
+import HbBaseSetting from './components/hbBaseSetting.vue'
+import HbScopePicker from './components/hbScopePicker.vue'
+
+/**
+ * 手动红包新建/编辑页面。
+ * 读取客户选择页暂存的多选结果,校验红包规则后批量提交,成功进入发放结果页。
+ */
 export default {
   components: {
-    MxDatePicker
+    HbBaseSetting,
+    HbScopePicker
   },
   data() {
     return {
-      userId: 0,
-      customId: 0,
-      customName: '',
-      form: {
-        count: 1,
-        amount: '',
-        days: '',
-        minConsume: '',
-        remark: '',
-        effectiveDate: ''
-      },
-      datePickerShow: false,
-      datePickerValue: ''
-    };
+      rules: [{
+        name: '',
+        hbAmount: '',
+        hbNum: 1,
+        miniCost: '',
+        duration: '',
+        effectiveType: 1,
+        effectiveDate: '',
+        effectiveDays: 0
+      }],
+      customs: [],
+      scopeType: 1,
+      scopeValue: '',
+      specialApplicable: 1,
+      remark: '',
+      submitting: false
+    }
+  },
+  computed: {
+    customerText() {
+      return this.customs.length ? `已选择${this.customs.length}人` : '请选择客户'
+    }
   },
   onLoad(options) {
-    if (options.customId) {
-      this.userId = options.userId;
-      this.customId = options.customId;
-      this.customName = options.customName || '';
+    const hasCustomer = options && (options.customId || options.userId)
+    if (hasCustomer) {
+      this.customs = [{
+        id: Number(options.customId) || options.customId,
+        customId: Number(options.customId) || options.customId,
+        userId: Number(options.userId) || options.userId,
+        name: options.customName || ''
+      }]
     }
+    uni.setNavigationBarTitle({ title: hasCustomer ? '编辑手动发放' : '新建手动发放' })
+  },
+  onShow() {
+    this.readSelectedCustomers()
   },
   methods: {
-    init() {},
-    openDatePicker() {
-      this.datePickerValue = this.form.effectiveDate || '';
-      this.datePickerShow = true;
+    /**
+     * 读取客户多选页写入的暂存结果并立即清除,防止返回页面时重复覆盖。
+     */
+    readSelectedCustomers() {
+      const raw = uni.getStorageSync('hb_selected_customs')
+      if (!raw) return
+      try {
+        const selected = typeof raw === 'string' ? JSON.parse(raw) : raw
+        if (Array.isArray(selected)) this.customs = selected
+      } catch (error) {
+        this.$msg('客户选择结果读取失败,请重新选择')
+      }
+      uni.removeStorageSync('hb_selected_customs')
     },
-    confirmDatePicker(e) {
-      this.form.effectiveDate = e.value;
-      this.datePickerShow = false;
+    /**
+     * 跳转客户页开启红包专用多选模式。
+     */
+    selectCustomers() {
+      uni.navigateTo({ url: '/admin/custom/selectCustom?style=5&multi=1' })
     },
-    cancel() {
-      uni.navigateBack();
+    /**
+     * 删除已选客户,不影响其他客户配置。
+     */
+    removeCustomer(customId) {
+      this.customs = this.customs.filter((item) => String(item.customId) !== String(customId))
     },
-    confirm() {
-      if (!this.form.amount) {
-        this.$msg('请输入金额');
-        return;
-      }
-      if (!this.form.count) {
-        this.$msg('请输入红包个数');
-        return;
-      }
-      if (!this.form.minConsume && isNaN(this.form.minConsume)) {
-        this.$msg('请输入最低消费');
-        return;
+    /**
+     * 接收适用范围组件返回的完整配置。
+     */
+    onScopeChange(value) {
+      this.scopeType = value.scopeType
+      this.scopeValue = value.scopeValue
+      this.specialApplicable = value.specialApplicable
+    },
+    /**
+     * 校验每条红包规则,金额保留两位、数量与时长使用非负整数。
+     */
+    validateRules() {
+      if (!this.rules.length) {
+        this.$msg('请至少添加一个红包')
+        return false
       }
-      // form.amount 小数点后只能两位
-      if (this.form.amount.split('.')[1] && this.form.amount.split('.')[1].length > 2) {
-        this.$msg('红包金额最小到分');
-        return;
+      for (let i = 0; i < this.rules.length; i++) {
+        const item = this.rules[i]
+        const prefix = `第${i + 1}个红包`
+        // 红包名称由后端生成,前端不再录入与校验
+        if (!item.hbAmount || Number(item.hbAmount) <= 0) {
+          this.$msg(`${prefix}请输入正确金额`)
+          return false
+        }
+        if (item.miniCost === '' || Number(item.miniCost) < 0) {
+          this.$msg(`${prefix}请输入最低消费`)
+          return false
+        }
+        if (!item.hbNum || !this.isInteger(item.hbNum, 1)) {
+          this.$msg(`${prefix}发放数量必须为正整数`)
+          return false
+        }
+        if (item.duration === '' || !this.isInteger(item.duration, 0)) {
+          this.$msg(`${prefix}有效时长必须为非负整数`)
+          return false
+        }
+        if (!this.hasTwoDecimals(item.hbAmount) || !this.hasTwoDecimals(item.miniCost)) {
+          this.$msg(`${prefix}金额最多保留两位小数`)
+          return false
+        }
       }
-      if (this.form.minConsume.split('.')[1] && this.form.minConsume.split('.')[1].length > 2) {
-        this.$msg('最低消费最小到分');
-        return;
+      return true
+    },
+    /**
+     * 判断值是否为不小于指定最小值的整数。
+     */
+    isInteger(value, min) {
+      const number = Number(value)
+      return Number.isInteger(number) && number >= min
+    },
+    /**
+     * 判断金额是否最多保留两位小数。
+     */
+    hasTwoDecimals(value) {
+      const parts = String(value).split('.')
+      return !parts[1] || parts[1].length <= 2
+    },
+    /**
+     * 提交前校验客户、范围和红包规则,避免无效批量请求。
+     */
+    submit() {
+      if (this.submitting) return
+      if (!this.customs.length) {
+        this.$msg('请选择客户')
+        return
       }
-      // 有效时长只能是正整数
-      if (isNaN(this.form.days) || this.form.days % 1 !== 0 || this.form.days < 0) {
-        this.$msg('有效时长必须为正整数');
-        return;
+      if (this.scopeType !== 1 && !this.scopeValue) {
+        this.$msg(this.scopeType === 2 ? '请选择花束分类' : '请选择花束商品')
+        return
       }
-      this.$util.confirmModal({content:'确认发放?'},() => {
-        createHb({ userId: this.userId, customId: this.customId, ...this.form, }).then(res => {
-          if (res.code == 1) {
-            this.$util.pageTo({url: '/admin/hb/result?id='+res.data.id,type:2})
-          }
-        })
+      if (!this.validateRules()) return
+      this.submitting = true
+      createHb({
+        customs: this.customs,
+        rules: this.rules,
+        scopeType: this.scopeType,
+        scopeValue: this.scopeValue,
+        specialApplicable: this.specialApplicable,
+        remark: this.remark
+      }).then((res) => {
+        if (res.code == 1) {
+          this.$util.pageTo({ url: `/admin/hb/result?id=${res.data.id || ''}`, type: 2 })
+        }
+      }, () => {
+        // 网络异常由全局请求层提示,此处仅恢复按钮状态。
+      }).then(() => {
+        this.submitting = false
       })
+    },
+    /**
+     * 取消编辑并返回上一级。
+     */
+    cancel() {
+      uni.navigateBack()
     }
   }
-};
+}
 </script>
 
 <style lang="scss" scoped>
-.app-content {
-  padding-top: 20upx;
-  background-color: #fff;
+.send-page {
   min-height: 100vh;
+  padding: 24upx;
+  background: #f5f6f7;
+  box-sizing: border-box;
+}
+.section-title {
+  margin-bottom: 20upx;
+  padding-left: 16upx;
+  border-left: 8upx solid #09c567;
+  color: #222;
+  font-size: 34upx;
+  font-weight: bold;
 }
-.form-item {
+.send-card {
+  overflow: hidden;
+  border-radius: 16upx;
+  background: #fff;
+}
+.customer-row,
+.remark-row {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 30upx 40upx;
+  min-height: 94upx;
+  padding: 0 30upx;
   border-bottom: 1upx solid #f5f5f5;
-  font-size: 30upx;
-  .label {
-    color: #333;
-    width: 200upx;
-  }
-  .value {
-    flex: 1;
-    text-align: right;
-    color: #333;
-  }
-  .input {
-    flex: 1;
-    text-align: right;
-    font-size: 30upx;
-  }
 }
-.footer-btn {
-  position: fixed;
-  bottom: 0;
-  left: 0;
-  right: 0;
+.label {
+  width: 180upx;
+  color: #333;
+  font-size: 29upx;
+}
+.value {
+  flex: 1;
+  color: #666;
+  text-align: right;
+  font-size: 27upx;
+}
+.customer-tags {
   display: flex;
-  padding: 20upx 40upx;
-  background-color: #fff;
-  justify-content: space-between;
-  box-shadow: 0 -2upx 10upx rgba(0,0,0,0.05);
-  .cancel-btn {
-    width: 45%;
-    background-color: #fff;
-    border: 1upx solid #ddd;
-    color: #666;
-    font-size: 32upx;
-    line-height: 80upx;
-    height: 80upx;
-  }
-  .confirm-btn {
-    width: 45%;
-    background-color: #09C567;
-    color: #fff;
-    font-size: 32upx;
-    line-height: 80upx;
-    height: 80upx;
-  }
+  flex-wrap: wrap;
+  padding: 6upx 30upx 24upx;
+  border-bottom: 1upx solid #f5f5f5;
 }
-.date-picker-mask {
+.customer-tag {
+  display: flex;
+  align-items: center;
+  margin-top: 18upx;
+  margin-right: 16upx;
+  padding: 10upx 16upx;
+  border-radius: 28upx;
+  background: #e8fff3;
+  color: #09a858;
+  font-size: 25upx;
+}
+.remove {
+  margin-left: 12upx;
+  color: #f53f3f;
+  font-size: 32upx;
+}
+.remark-input {
+  flex: 1;
+  text-align: right;
+  font-size: 28upx;
+}
+.footer-placeholder {
+  height: 130upx;
+}
+.footer-bar {
   position: fixed;
-  top: 0;
-  left: 0;
+  z-index: 90;
   right: 0;
   bottom: 0;
-  background-color: rgba(0, 0, 0, 0.5);
-  z-index: 99;
-  animation: fadeIn 0.3s ease-in-out;
+  left: 0;
+  display: flex;
+  padding: 20upx 36upx;
+  background: #fff;
+  box-shadow: 0 -3upx 15upx rgba(0, 0, 0, 0.06);
+}
+.cancel-btn,
+.submit-btn {
+  width: 48%;
+  height: 80upx;
+  line-height: 80upx;
+  font-size: 30upx;
+}
+.cancel-btn {
+  margin-right: 4%;
+  border: 1upx solid #ddd;
+  background: #fff;
+  color: #666;
+}
+.submit-btn {
+  background: #09c567;
+  color: #fff;
+}
+.cancel-btn::after,
+.submit-btn::after {
+  border: 0;
 }
 </style>
-

+ 676 - 0
hdApp/src/admin/hb/stat.vue

@@ -0,0 +1,676 @@
+<!--
+  红包统计页面,供门店管理员查看发放、使用、失效和金额汇总。
+  支持按时间与红包类型筛选,并将统计数字下钻到红包明细列表。
+-->
+<template>
+  <view class="stat-page">
+    <view class="time-tabs">
+      <view
+        v-for="tab in dateTabs"
+        :key="tab.value"
+        class="time-tab"
+        :class="{ active: filters.dateType === tab.value }"
+        @click="changeDateType(tab.value)"
+      >
+        {{ tab.label }}
+      </view>
+    </view>
+
+    <view v-if="filters.dateType === 'custom'" class="custom-date">
+      <picker mode="date" :value="filters.beginDate" @change="changeBeginDate">
+        <view class="date-value">{{ filters.beginDate || '开始日期' }}</view>
+      </picker>
+      <text class="date-line">至</text>
+      <picker mode="date" :value="filters.endDate" @change="changeEndDate">
+        <view class="date-value">{{ filters.endDate || '结束日期' }}</view>
+      </picker>
+      <button class="query-btn" @click="queryCustomDate">查询</button>
+    </view>
+
+    <view class="filter-row" @click="typePopupShow = true">
+      <text>红包类型</text>
+      <text class="filter-value">{{ currentTypeName }} ›</text>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">发放汇总</view>
+      <!-- 2x2 统计卡:左侧按字段色渲染红包图标,右侧展示标签与数量,点击下钻列表 -->
+      <view class="count-grid">
+        <view class="count-item" @click="goList('')">
+          <view class="count-icon issued-bg">
+            <view class="count-icon-svg">
+              <zui-svg-icon icon="general-red-packet" :width="25" :height="25" color="#f53f3f" />
+            </view>
+          </view>
+          <view class="count-content">
+            <text class="count-label">发放数量</text>
+            <view class="count-value-row">
+              <text class="count-number issued">{{ issueSummary.total }}</text>
+              <text class="count-unit">张</text>
+            </view>
+          </view>
+        </view>
+        <view class="count-item" @click="goList('1')">
+          <view class="count-icon used-bg">
+            <view class="count-icon-svg">
+              <zui-svg-icon icon="general-red-packet" :width="25" :height="25" color="#00b42a" />
+            </view>
+          </view>
+          <view class="count-content">
+            <text class="count-label">已使用</text>
+            <view class="count-value-row">
+              <text class="count-number used">{{ issueSummary.used }}</text>
+              <text class="count-unit">张</text>
+            </view>
+          </view>
+        </view>
+        <view class="count-item" @click="goList('0')">
+          <view class="count-icon unused-bg">
+            <view class="count-icon-svg">
+              <zui-svg-icon icon="general-red-packet" :width="25" :height="25" color="#ff7d00" />
+            </view>
+          </view>
+          <view class="count-content">
+            <text class="count-label">未使用</text>
+            <view class="count-value-row">
+              <text class="count-number unused">{{ issueSummary.unused }}</text>
+              <text class="count-unit">张</text>
+            </view>
+          </view>
+        </view>
+        <view class="count-item" @click="goList('-1')">
+          <view class="count-icon expired-bg">
+            <view class="count-icon-svg">
+              <zui-svg-icon icon="general-red-packet" :width="25" :height="25" color="#86909c" />
+            </view>
+          </view>
+          <view class="count-content">
+            <text class="count-label">已失效</text>
+            <view class="count-value-row">
+              <text class="count-number expired">{{ issueSummary.expired }}</text>
+              <text class="count-unit">张</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <!-- 使用率:左侧文案、中间进度条、右侧百分比,与汇总数字联动 -->
+      <view class="rate-row">
+        <text class="rate-label">使用率</text>
+        <view class="progress-track">
+          <view class="progress-value" :style="{ width: useRate + '%' }"></view>
+        </view>
+        <text class="rate-value">{{ useRate }}%</text>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">金额汇总</view>
+      <view class="amount-grid">
+        <view class="amount-item">
+          <text class="amount-number">¥{{ amountSummary.total }}</text>
+          <text class="amount-label">发放金额</text>
+        </view>
+        <view class="amount-item">
+          <text class="amount-number">¥{{ amountSummary.used }}</text>
+          <text class="amount-label">已使用</text>
+        </view>
+        <view class="amount-item">
+          <text class="amount-number">¥{{ amountSummary.unused }}</text>
+          <text class="amount-label">未使用</text>
+        </view>
+        <view class="amount-item">
+          <text class="amount-number">¥{{ amountSummary.expired }}</text>
+          <text class="amount-label">已失效</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card type-card">
+      <view class="section-title">红包类型统计</view>
+      <view v-if="typeStats.length" class="type-header">
+        <text class="type-name">类型</text>
+        <text>发放</text>
+        <text>已使用</text>
+        <text>未使用</text>
+        <text>已失效</text>
+      </view>
+      <view v-for="item in typeStats" :key="item.getType" class="type-row" @click="goTypeList(item)">
+        <text class="type-name">{{ item.getTypeName || formatGetType(item.getType) }}</text>
+        <text>{{ typeTotal(item) }}</text>
+        <text class="used">{{ typeUsed(item) }}</text>
+        <text class="unused">{{ typeUnused(item) }}</text>
+        <text class="expired">{{ typeExpired(item) }}</text>
+      </view>
+      <view v-if="!typeStats.length" class="empty-text">暂无统计数据</view>
+    </view>
+
+    <view v-if="typePopupShow" class="overlay" @click="typePopupShow = false">
+      <view class="type-panel" @click.stop>
+        <view class="panel-title">选择红包类型</view>
+        <view
+          v-for="option in typeOptions"
+          :key="option.key"
+          class="type-option"
+          :class="{ active: isTypeSelected(option.value) }"
+          @click="selectGetType(option.value)"
+        >
+          {{ option.label }}
+        </view>
+        <button class="panel-cancel" @click="typePopupShow = false">取消</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { hbStat } from '@/api/hb'
+
+/**
+ * 红包统计页。
+ * 根据日期类型与红包类型拉取汇总数据,点击统计项会携带当前条件进入明细列表。
+ */
+export default {
+  data() {
+    return {
+      dateTabs: [
+        { label: '今天', value: 'today' },
+        { label: '近7天', value: '7' },
+        { label: '近30天', value: '30' },
+        { label: '自定义', value: 'custom' }
+      ],
+      typeOptions: [
+        { key: 'all', label: '全部红包', value: '' },
+        { key: '0', label: '新客红包', value: 0 },
+        { key: '1', label: '赠送红包', value: 1 },
+        { key: '2', label: '充值送红包', value: 2 },
+        { key: '3', label: '受邀新客红包', value: 3 },
+        { key: '4', label: '沉睡召回红包', value: 4 },
+        { key: '5', label: '会员日红包', value: 5 }
+      ],
+      filters: {
+        dateType: 'today',
+        beginDate: '',
+        endDate: '',
+        getType: ''
+      },
+      statData: {},
+      typePopupShow: false
+    }
+  },
+  computed: {
+    currentTypeName() {
+      const current = this.typeOptions.find((item) => String(item.value) === String(this.filters.getType))
+      return current ? current.label : '全部红包'
+    },
+    issueSummary() {
+      // 后端 HbClass::getStat 汇总字段在 summary 下:issueCount/usedCount/unusedCount/expiredCount
+      const source = this.statData.issueSummary || this.statData.countSummary || this.statData.summary || this.statData
+      return {
+        total: this.statNumber(source, ['issueCount', 'total', 'sendCount', 'grantCount', 'issuedCount', 'all', 'count']),
+        used: this.statNumber(source, ['usedCount', 'used', 'useCount']),
+        unused: this.statNumber(source, ['unusedCount', 'unused', 'unUse', 'unUseCount']),
+        expired: this.statNumber(source, ['expiredCount', 'expired', 'invalidCount'])
+      }
+    },
+    amountSummary() {
+      // 金额同样落在 summary:issueAmount/usedAmount/unusedAmount/expiredAmount
+      const source = this.statData.amountSummary || this.statData.amount || this.statData.summary || {}
+      return {
+        total: this.statAmount(source, ['issueAmount', 'total', 'totalAmount', 'sendAmount', 'grantAmount', 'issuedAmount', 'allAmount']),
+        used: this.statAmount(source, ['usedAmount', 'used']),
+        unused: this.statAmount(source, ['unusedAmount', 'unused', 'unUseAmount']),
+        expired: this.statAmount(source, ['expiredAmount', 'expired'])
+      }
+    },
+    useRate() {
+      const summary = this.statData.issueSummary || this.statData.countSummary || this.statData.summary || {}
+      const rate = this.statData.useRate !== undefined ? this.statData.useRate : (this.statData.usageRate !== undefined ? this.statData.usageRate : summary.useRate)
+      const sourceRate = rate !== undefined
+        ? Number(rate)
+        : (this.issueSummary.total ? this.issueSummary.used * 100 / this.issueSummary.total : 0)
+      return Math.max(0, Math.min(100, Number(sourceRate.toFixed(1))))
+    },
+    typeStats() {
+      return this.statData.typeStats || this.statData.typeStat || this.statData.typeList || this.statData.list || []
+    }
+  },
+  onLoad() {
+    this.loadStat()
+  },
+  methods: {
+    /**
+     * 判断弹层红包类型是否选中,避免小程序模板直接执行字符串转换。
+     */
+    isTypeSelected(type) {
+      return String(this.filters.getType) === String(type)
+    },
+    /**
+     * 从兼容字段中提取整数,适配统计接口不同版本的命名。
+     */
+    statNumber(source, keys) {
+      for (let i = 0; i < keys.length; i++) {
+        if (source && source[keys[i]] !== undefined) return Number(source[keys[i]]) || 0
+      }
+      return 0
+    },
+    /**
+     * 从兼容字段中提取金额并统一显示两位小数。
+     */
+    statAmount(source, keys) {
+      for (let i = 0; i < keys.length; i++) {
+        if (source && source[keys[i]] !== undefined) return (Number(source[keys[i]]) || 0).toFixed(2)
+      }
+      return '0.00'
+    },
+    /**
+     * 提取类型统计发放数。
+     */
+    typeTotal(item) {
+      return this.statNumber(item, ['issueCount', 'total', 'sendCount', 'grantCount', 'issuedCount', 'all'])
+    },
+    /**
+     * 提取类型统计已使用数。
+     */
+    typeUsed(item) {
+      return this.statNumber(item, ['usedCount', 'used', 'useCount'])
+    },
+    /**
+     * 提取类型统计未使用数。
+     */
+    typeUnused(item) {
+      return this.statNumber(item, ['unusedCount', 'unused', 'unUse', 'unUseCount'])
+    },
+    /**
+     * 提取类型统计已失效数。
+     */
+    typeExpired(item) {
+      return this.statNumber(item, ['expiredCount', 'expired', 'invalidCount'])
+    },
+    /**
+     * 切换快捷日期并立即查询;自定义日期等待用户确认。
+     */
+    changeDateType(type) {
+      this.filters.dateType = type
+      if (type !== 'custom') {
+        this.filters.beginDate = ''
+        this.filters.endDate = ''
+        this.loadStat()
+      }
+    },
+    /**
+     * 更新自定义开始日期。
+     */
+    changeBeginDate(event) {
+      this.filters.beginDate = event.detail.value
+    },
+    /**
+     * 更新自定义结束日期。
+     */
+    changeEndDate(event) {
+      this.filters.endDate = event.detail.value
+    },
+    /**
+     * 校验自定义日期完整性和先后顺序后查询。
+     */
+    queryCustomDate() {
+      if (!this.filters.beginDate || !this.filters.endDate) {
+        this.$msg('请选择完整时间范围')
+        return
+      }
+      if (this.filters.beginDate > this.filters.endDate) {
+        this.$msg('开始日期不能晚于结束日期')
+        return
+      }
+      this.loadStat()
+    },
+    /**
+     * 选择红包类型并按当前日期条件刷新统计。
+     */
+    selectGetType(type) {
+      this.filters.getType = type
+      this.typePopupShow = false
+      this.loadStat()
+    },
+    /**
+     * 拉取统计数据,并采用接口返回的标准日期供列表下钻。
+     */
+    loadStat() {
+      hbStat(this.filters).then((res) => {
+        if (res.code != 1) return
+        this.statData = res.data || {}
+        if (res.data && res.data.beginDate) this.filters.beginDate = res.data.beginDate
+        if (res.data && res.data.endDate) this.filters.endDate = res.data.endDate
+      })
+    },
+    /**
+     * 下钻当前筛选条件到红包列表,并可指定使用状态。
+     */
+    goList(status, getType) {
+      const type = getType !== undefined ? getType : this.filters.getType
+      const query = [
+        `beginDate=${encodeURIComponent(this.filters.beginDate || '')}`,
+        `endDate=${encodeURIComponent(this.filters.endDate || '')}`,
+        `getType=${encodeURIComponent(type)}`,
+        `status=${encodeURIComponent(status)}`
+      ].join('&')
+      uni.navigateTo({ url: `/admin/hb/list?${query}` })
+    },
+    /**
+     * 点击类型统计行时进入该类型的全部红包明细。
+     */
+    goTypeList(item) {
+      this.goList('', item.getType)
+    },
+    /**
+     * 兜底格式化红包类型名称,接口未返回名称时仍可展示。
+     */
+    formatGetType(type) {
+      const names = {
+        0: '新客红包',
+        1: '赠送红包',
+        2: '充值送红包',
+        3: '受邀新客红包',
+        4: '沉睡召回红包',
+        5: '会员日红包'
+      }
+      return names[type] || '未知红包'
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.stat-page {
+  min-height: 100vh;
+  padding: 20upx;
+  background: #f5f6f7;
+  box-sizing: border-box;
+}
+.time-tabs {
+  display: flex;
+  padding: 8upx;
+  border-radius: 12upx;
+  background: #fff;
+}
+.time-tab {
+  flex: 1;
+  padding: 18upx 0;
+  border-radius: 8upx;
+  color: #666;
+  text-align: center;
+  font-size: 27upx;
+}
+.time-tab.active {
+  background: #e8fff3;
+  color: #09c567;
+  font-weight: bold;
+}
+.custom-date {
+  display: flex;
+  align-items: center;
+  margin-top: 16upx;
+  padding: 20upx;
+  border-radius: 12upx;
+  background: #fff;
+}
+.custom-date picker {
+  flex: 1;
+}
+.date-value {
+  padding: 16upx 8upx;
+  border: 1upx solid #ddd;
+  border-radius: 8upx;
+  color: #555;
+  text-align: center;
+  font-size: 25upx;
+}
+.date-line {
+  margin: 0 12upx;
+  color: #999;
+}
+.query-btn {
+  height: 62upx;
+  line-height: 62upx;
+  margin: 0 0 0 14upx;
+  padding: 0 22upx;
+  background: #09c567;
+  color: #fff;
+  font-size: 25upx;
+}
+.query-btn::after {
+  border: 0;
+}
+.filter-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 16upx;
+  padding: 28upx 30upx;
+  border-radius: 12upx;
+  background: #fff;
+  color: #333;
+  font-size: 29upx;
+}
+.filter-value {
+  color: #666;
+}
+.section-card {
+  margin-top: 20upx;
+  padding: 30upx;
+  border-radius: 16upx;
+  background: #fff;
+}
+.section-title {
+  margin-bottom: 26upx;
+  padding-left: 14upx;
+  border-left: 7upx solid #09c567;
+  color: #222;
+  font-size: 32upx;
+  font-weight: bold;
+}
+.count-grid {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.count-item {
+  display: flex;
+  align-items: center;
+  width: 48%;
+  margin-bottom: 16upx;
+  padding: 24upx 20upx;
+  border: 1upx solid #eceff2;
+  border-radius: 16upx;
+  box-sizing: border-box;
+}
+.count-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 72upx;
+  height: 72upx;
+  margin-right: 16upx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.count-icon-svg {
+  margin-top: 6upx;
+}
+.issued-bg {
+  background: rgba(245, 63, 63, 0.1);
+}
+.used-bg {
+  background: rgba(0, 180, 42, 0.1);
+}
+.unused-bg {
+  background: rgba(255, 125, 0, 0.1);
+}
+.expired-bg {
+  background: rgba(134, 144, 156, 0.12);
+}
+.count-content {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.count-label {
+  color: #86909c;
+  font-size: 24upx;
+}
+.count-value-row {
+  display: flex;
+  align-items: baseline;
+  margin-top: 6upx;
+}
+.count-number {
+  font-size: 36upx;
+  font-weight: bold;
+  line-height: 1.2;
+}
+.count-unit {
+  margin-left: 4upx;
+  color: #4e5969;
+  font-size: 22upx;
+}
+.issued {
+  color: #f53f3f;
+}
+.used {
+  color: #00b42a;
+}
+.unused {
+  color: #ff7d00;
+}
+.expired {
+  color: #86909c;
+}
+.rate-row {
+  display: flex;
+  align-items: center;
+  margin-top: 10upx;
+}
+.rate-label {
+  margin-right: 16upx;
+  color: #4e5969;
+  font-size: 26upx;
+  flex-shrink: 0;
+}
+.progress-track {
+  flex: 1;
+  height: 16upx;
+  overflow: hidden;
+  border-radius: 8upx;
+  background: #e8f8ee;
+}
+.progress-value {
+  height: 100%;
+  border-radius: 8upx;
+  background: #00b42a;
+}
+.rate-value {
+  margin-left: 16upx;
+  color: #00b42a;
+  font-size: 28upx;
+  font-weight: bold;
+  flex-shrink: 0;
+}
+.amount-grid {
+  display: flex;
+}
+.amount-item {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  align-items: center;
+  min-width: 0;
+}
+.amount-number {
+  color: #333;
+  font-size: 28upx;
+  font-weight: bold;
+}
+.amount-label {
+  margin-top: 12upx;
+  color: #999;
+  font-size: 23upx;
+}
+.type-card {
+  margin-bottom: 20upx;
+}
+.type-header,
+.type-row {
+  display: flex;
+  align-items: center;
+  min-height: 76upx;
+  border-bottom: 1upx solid #f3f3f3;
+}
+.type-header {
+  color: #999;
+  font-size: 23upx;
+}
+.type-row {
+  color: #555;
+  font-size: 25upx;
+}
+.type-header text,
+.type-row text {
+  flex: 1;
+  text-align: center;
+}
+.type-header .type-name,
+.type-row .type-name {
+  flex: 1.5;
+  text-align: left;
+}
+.empty-text {
+  padding: 50upx 0 30upx;
+  color: #999;
+  text-align: center;
+  font-size: 26upx;
+}
+.overlay {
+  position: fixed;
+  z-index: 999;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background: rgba(0, 0, 0, 0.45);
+}
+.type-panel {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: 30upx;
+  border-radius: 24upx 24upx 0 0;
+  background: #fff;
+}
+.panel-title {
+  padding-bottom: 20upx;
+  color: #222;
+  text-align: center;
+  font-size: 32upx;
+  font-weight: bold;
+}
+.type-option {
+  padding: 22upx 10upx;
+  border-bottom: 1upx solid #f5f5f5;
+  color: #555;
+  text-align: center;
+  font-size: 29upx;
+}
+.type-option.active {
+  color: #09c567;
+  font-weight: bold;
+}
+.panel-cancel {
+  margin-top: 22upx;
+  background: #f5f5f5;
+  color: #555;
+  font-size: 28upx;
+}
+.panel-cancel::after {
+  border: 0;
+}
+</style>

+ 3 - 2
hdApp/src/admin/home/homeMenus.js

@@ -16,8 +16,7 @@ export const APPLY_MENU_GROUPS = [
       { name: '使用场景', icon: homeIcon('category-filled'), url: '/admin/useCase/list', pf: 1 },
       { name: '排序', icon: homeIcon('sort-filled'), url: '/admin/goods/categorySort', pf: 1 },
       { name: '任务', icon: homeIcon('task-filled'), url: '/admin/work/list', pf: 1 },
-      { name: '涨价设置', icon: homeIcon('price-increase'), url: '/admin/goods/price-increase', pf: 1 },
-      { name: '首页配置', icon: homeIcon('settings-filled'), url: '/admin/homePageConfig/index', pf: 1 }
+      { name: '涨价设置', icon: homeIcon('price-increase'), url: '/admin/goods/price-increase', pf: 1 }
     ]
   },
   {
@@ -46,6 +45,7 @@ export const APPLY_MENU_GROUPS = [
     titleIcon: homeIcon('shop-filled'),
     list: [
       { name: '门店', icon: homeIcon('shop-filled'), url: '/admin/shop/list', pf: 1 },
+      { name: '首页配置', icon: homeIcon('settings-filled'), url: '/admin/homePageConfig/index', pf: 1 },
       { name: '员工', icon: homeIcon('staff'), url: '/admin/staff/list', pf: 1 },
       { name: '员工业绩', icon: homeIcon('staff-performance'), url: '/admin/staff/achieve', pf: 1 },
       { name: '失信人员', icon: homeIcon('dishonest-person'), url: '/admin/ll/list', pf: 1 },
@@ -86,6 +86,7 @@ export const APPLY_MENU_GROUPS = [
       { name: '损耗总览', icon: homeIcon('breakage'), url: '/admin/stat/waste', pf: 1 },
       { name: '挂账汇总', icon: homeIcon('pay-code'), url: '/admin/custom/showTotalBalance', pf: 1 },
       { name: '充值统计', icon: homeIcon('stats-2'), url: '/admin/stat/rechargeStat', pf: 1 },
+      { name: '红包统计', icon: homeIcon('red-packet'), url: '/admin/hb/stat', pf: 1 }
     ]
   },
   {

+ 20 - 0
hdApp/src/api/hb/index.js

@@ -8,6 +8,11 @@ export const hbList = data => {
 	return https.get('/hb/hb-list', data)
 }
 
+/** 红包统计 */
+export const hbStat = data => {
+	return https.get('/hb/stat', data)
+}
+
 export const getAvailableHb = data => {
 	return https.get('/hb/available-hb', data)
 }
@@ -46,4 +51,19 @@ export const sendHbRule = data => {
 //充值送红包规则删除
 export const deleteHbRule = data => {
 	return https.post('/hb/delete-hb-rule', data)
+}
+
+/** 自动发红包:卡片列表 */
+export const getAutoRuleList = data => {
+	return https.get('/hb/auto-rule-list', data)
+}
+
+/** 自动发红包:规则详情 */
+export const getAutoRuleDetail = data => {
+	return https.get('/hb/auto-rule-detail', data)
+}
+
+/** 自动发红包:保存规则 */
+export const saveAutoRule = data => {
+	return https.post('/hb/save-auto-rule', data)
 }

File diff suppressed because it is too large
+ 0 - 0
hdApp/src/assets/svg-icons/general/red-packet.svg


+ 4 - 1
hdApp/src/pages.json

@@ -697,7 +697,10 @@
 				{ "path": "list", "style": { "navigationBarTitleText": "红包列表", "enablePullDownRefresh": true } },
 				{ "path": "send", "style": { "navigationBarTitleText": "发红包" } },
 				{ "path": "sendHb", "style": { "navigationBarTitleText": "创建充值送红包" } },
-				{ "path": "result", "style": { "navigationBarTitleText": "发放成功" } }
+				{ "path": "result", "style": { "navigationBarTitleText": "发放成功" } },
+				{ "path": "stat", "style": { "navigationBarTitleText": "红包统计" } },
+				{ "path": "autoSend/list", "style": { "navigationBarTitleText": "自动发红包" } },
+				{ "path": "autoSend/edit", "style": { "navigationBarTitleText": "配置红包" } }
 			]
 		}
 	],

File diff suppressed because it is too large
+ 5 - 1
hdApp/src/static/svg-icons-lib.js


+ 1 - 0
mallApp/.gitignore

@@ -3,6 +3,7 @@ node_modules/
 unpackage/
 /dist/dev
 /dist/build/mp-weixin
+/dist/build/h5
 # local env files
 .env.local
 .env.*.local

+ 20 - 0
mallApp/src/api/hb/index.js

@@ -1,3 +1,7 @@
+/**
+ * 红包接口
+ * 商城红包列表、到账提醒与结算可用红包统一从这里请求,避免页面直接拼接接口地址。
+ */
 import https from '@/plugins/luch-request_0.0.7/request'
 
 /** *
@@ -14,6 +18,22 @@ export const getAvailableHb = data => {
 	return https.get('/hb/available-hb', data)
 }
 
+/**
+ * 查询当前用户是否有尚未提醒的到账红包。
+ * data 可传 shopId 限定门店,不传时由后端按当前上下文查询。
+ */
+export const checkArrival = data => {
+	return https.get('/hb/check-arrival', data)
+}
+
+/**
+ * 标记到账红包已提醒,避免首页重复弹出。
+ * data 支持 hbIds 数组或单个 hbId。
+ */
+export const markNotified = data => {
+	return https.post('/hb/mark-notified', data)
+}
+
 // =====================  B 端 ========================
 /** *
  * 优惠券列表 b

+ 27 - 11
mallApp/src/components/hb/hb-select.vue

@@ -1,3 +1,7 @@
+<!--
+  结算红包选择组件
+  根据购物车商品范围、活动属性和最低消费筛选可用红包,并展示红包适用范围。
+-->
 <template>
 	<view class="hb-select-container">
 		<!-- 触发区:hideTrigger 时由页面自定义行样式,仅保留弹层 -->
@@ -39,6 +43,7 @@
 								<view class="name">{{ item.name }}</view>
 								<view class="condition" v-if="Number(item.minConsume) > 0"> 满{{ item.minConsume?parseFloat(item.minConsume):0 }}可用 </view>
 								<view class="condition" v-else>无门槛</view>
+								<view class="scope">适用:{{ item.scopeText || '全部商品适用' }}</view>
 								<view class="date" v-if="item.endTime == 4102416000">永久有效</view>
 								<view class="date" v-else>{{ formatTime(item.endTime) }} 到期</view>
 							</view>
@@ -73,13 +78,14 @@
 </template>
 
 <script>
-import TuiListCell from "@/components/plugin/list-cell";
+/**
+ * 红包选择组件
+ * productList 中的 hbLineAmount 由结算页价格引擎提供,确保范围金额与订单商品金额一致。
+ */
+import { isHbAvailable } from "@/utils/hbScope";
 
 export default {
 	name: "HbSelect",
-	components: {
-		TuiListCell
-	},
 	props: {
 		hbData: {
 			type: Array,
@@ -89,6 +95,11 @@ export default {
 			type: [Number, String],
 			default: 0
 		},
+		/** 购物车商品明细,用于按分类、商品及活动属性计算红包适用金额 */
+		productList: {
+			type: Array,
+			default: () => []
+		},
 		value: { // v-model for selected Hb ID
 			type: [String, Number],
 			default: null
@@ -110,14 +121,13 @@ export default {
 		};
 	},
 	computed: {
+		/**
+		 * 按商品范围和最低消费筛选可用红包。
+		 * 最终资格仍以提交订单时后端校验为准。
+		 */
 		availableHbList() {
-			const price = Number(this.totalPrice) || 0;
-			return this.hbData.filter(item => {
-                // console.log(item)
-				const min = Number(item.minConsume) || 0;
-                // console.log('price, min ----- ', price, min)
-				// typically condition is total >= minConsume. 
-				return price >= min; 
+			return this.hbData.filter((item) => {
+				return isHbAvailable(item, this.productList, this.totalPrice);
 			});
 		},
 		hasAvailableHb() {
@@ -308,6 +318,12 @@ export default {
 	color: #666;
 	margin-bottom: 8upx;
 }
+.scope {
+	margin-bottom: 8upx;
+	font-size: 22upx;
+	color: #888;
+	line-height: 1.4;
+}
 .date {
 	font-size: 22upx;
 	color: #999;

+ 210 - 0
mallApp/src/components/module/app-hb-arrival.vue

@@ -0,0 +1,210 @@
+<!--
+  红包到账提醒弹框
+  店铺首页和个人首页在检测到新红包后使用,负责展示提醒、标记已通知并引导用户查看红包。
+-->
+<template>
+	<modal-module
+		:show="show"
+		:custom="true"
+		:mask-closable="false"
+		width="620upx"
+		padding="0"
+		radius="28upx"
+		bgcolor="#ffffff"
+	>
+		<view class="hb-arrival">
+			<view class="hb-arrival-icon">
+				<image
+					class="hb-arrival-image"
+					:src="`${constant.imgUrl}/retail/coupon/new-coupon-bg.png`"
+					mode="aspectFit"
+				/>
+			</view>
+			<text class="hb-arrival-title">新红包到账</text>
+			<text class="hb-arrival-subtitle">您有一个新红包,请及时查看</text>
+			<view v-if="info && info.amount" class="hb-arrival-amount">
+				<text class="hb-arrival-symbol">¥</text>
+				<text class="hb-arrival-number">{{ parseFloat(info.amount) }}</text>
+			</view>
+			<button class="hb-arrival-view" :disabled="submitting" @click="handleView">立即查看</button>
+			<button class="hb-arrival-later" :disabled="submitting" @click="handleLater">稍后再说</button>
+		</view>
+	</modal-module>
+</template>
+
+<script>
+/**
+ * 红包到账提醒组件
+ * 接收待提醒红包及其 id,用户作出选择后统一回写通知状态,防止后续重复弹窗。
+ */
+import ModalModule from '@/components/plugin/modal'
+import { markNotified } from '@/api/hb'
+
+export default {
+	name: 'AppHbArrival',
+	components: {
+		ModalModule
+	},
+	props: {
+		show: {
+			type: Boolean,
+			default: false
+		},
+		info: {
+			type: Object,
+			default: () => ({})
+		},
+		hbIds: {
+			type: Array,
+			default: () => []
+		}
+	},
+	data() {
+		return {
+			constant: this.$constant,
+			submitting: false
+		}
+	},
+	methods: {
+		/**
+		 * 生成通知回写参数。
+		 * 批量 id 优先,接口只返回单个红包时回退使用 info.id。
+		 */
+		buildNotifyParams() {
+			if (this.hbIds.length > 0) {
+				return { hbIds: this.hbIds }
+			}
+			if (this.info && this.info.id) {
+				return { hbId: this.info.id }
+			}
+			return null
+		},
+		/**
+		 * 标记红包已提醒并关闭弹框。
+		 * 即使网络失败也关闭当前弹框,避免阻塞用户继续操作;下次检测仍可由后端再次返回。
+		 */
+		notifyAndClose() {
+			if (this.submitting) {
+				return Promise.resolve()
+			}
+			this.submitting = true
+			const params = this.buildNotifyParams()
+			const request = params ? markNotified(params) : Promise.resolve()
+			this.$emit('update:show', false)
+			return request.catch(() => {}).finally(() => {
+				this.submitting = false
+			})
+		},
+		/**
+		 * 立即查看红包。
+		 * 先触发父级业务事件,再进入我的红包页,同时异步回写已提醒状态。
+		 */
+		handleView() {
+			this.$emit('view', this.info)
+			this.notifyAndClose()
+			uni.navigateTo({
+				url: '/pages/hb/list'
+			})
+		},
+		/**
+		 * 暂不查看红包,仅关闭弹框并回写已提醒状态。
+		 */
+		handleLater() {
+			this.$emit('later', this.info)
+			this.notifyAndClose()
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.hb-arrival {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	padding: 54upx 44upx 34upx;
+	background-color: #ffffff;
+	border-radius: 28upx;
+	box-sizing: border-box;
+}
+
+.hb-arrival-icon {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 150upx;
+	height: 150upx;
+	margin-bottom: 28upx;
+	border-radius: 75upx;
+	background-color: #eef9e9;
+	overflow: hidden;
+}
+
+.hb-arrival-image {
+	width: 112upx;
+	height: 112upx;
+}
+
+.hb-arrival-title {
+	font-size: 40upx;
+	font-weight: bold;
+	color: #222222;
+	line-height: 1.4;
+}
+
+.hb-arrival-subtitle {
+	margin-top: 14upx;
+	font-size: 27upx;
+	color: #888888;
+	line-height: 1.5;
+}
+
+.hb-arrival-amount {
+	display: flex;
+	flex-direction: row;
+	align-items: baseline;
+	margin-top: 24upx;
+	color: #77c34f;
+}
+
+.hb-arrival-symbol {
+	font-size: 30upx;
+	font-weight: bold;
+}
+
+.hb-arrival-number {
+	font-size: 54upx;
+	font-weight: bold;
+}
+
+.hb-arrival-view,
+.hb-arrival-later {
+	width: 100%;
+	margin: 0;
+	padding: 0;
+	border: none;
+
+	&::after {
+		border: none;
+	}
+}
+
+.hb-arrival-view {
+	height: 82upx;
+	margin-top: 34upx;
+	border-radius: 41upx;
+	background-color: #77c34f;
+	color: #ffffff;
+	font-size: 30upx;
+	line-height: 82upx;
+}
+
+.hb-arrival-later {
+	height: 68upx;
+	margin-top: 12upx;
+	background-color: transparent;
+	color: #999999;
+	font-size: 27upx;
+	line-height: 68upx;
+}
+</style>

+ 23 - 7
mallApp/src/pages/billing/affirmMix.vue

@@ -387,6 +387,7 @@
 			hide-trigger
 			:hbData="hbData"
 			:totalPrice="allPriceFun"
+			:product-list="hbProductList"
 			v-model="selectedHbId"
 			@change="onHbChange"
 		></hb-select>
@@ -499,6 +500,7 @@ import { getAvailableHb } from "@/api/hb";
 import { getAllPsMethod } from "@/api/ps-method";
 import MxDatePicker from "@/components/mx-datepicker/mx-datepicker.vue";
 import HbSelect from "@/components/hb/hb-select";
+import { isHbAvailable } from "@/utils/hbScope";
 
 /** 配送方式 style 与雪碧图图标映射(名称取自接口 name) */
 const SEND_TYPE_ICON = {
@@ -671,14 +673,27 @@ export default {
 				return this.getLimitBuyPriceDisplayParts(item);
 			});
 		},
+		/**
+		 * 红包范围计算使用的商品明细。
+		 * hbLineAmount 直接复用结算价格引擎,兼容花束、大小单位、限购价和满量价。
+		 */
+		hbProductList() {
+			return (this.list || []).map((item) => {
+				return {
+					...item,
+					hbLineAmount: this.getSelectItemTotalPrice(item)
+				};
+			});
+		},
+		/**
+		 * 是否至少有一个满足商品范围、活动限制及最低消费的红包。
+		 */
 		hasAvailableHb() {
 			if (!this.hbData || this.hbData.length === 0) {
 				return false;
 			}
-			const price = this.allPriceFun;
 			return this.hbData.some((item) => {
-				const min = Number(item.minConsume) || 0;
-				return price >= min;
+				return isHbAvailable(item, this.hbProductList, this.allPriceFun);
 			});
 		},
 		modifyPrice() {
@@ -1149,14 +1164,15 @@ export default {
 		onHbChange(hb) {
 			this.selectedHb = hb;
 		},
-		/** 商品金额变化后,取消不再满足门槛的红包 */
+		/**
+		 * 商品金额或明细变化后取消不可用红包。
+		 * 同时校验适用分类/商品、特价活动限制和最低消费,保持与选择弹层一致。
+		 */
 		syncSelectedHbAfterPriceChange() {
 			if (!this.selectedHbId) {
 				return;
 			}
-			const price = Number(this.allPriceFun) || 0;
-			const min = Number(this.selectedHb && this.selectedHb.minConsume) || 0;
-			if (price < min) {
+			if (!isHbAvailable(this.selectedHb, this.hbProductList, this.allPriceFun)) {
 				this.selectedHbId = null;
 				this.selectedHb = null;
 			}

+ 38 - 1
mallApp/src/pages/hb/list.vue

@@ -1,3 +1,7 @@
+<!--
+  我的红包列表
+  供商城用户按状态查看红包,并补充展示商品适用范围及特价、活动商品限制。
+-->
 <template>
 	<view class="app-content coupon-list">
 		<AppTabs :tabs="tabs" :isFixed="true" :currentTab="tabIndex" @change="change" itemWidth="33.3333%" />
@@ -28,6 +32,8 @@
 								<view class="coupon-message">
 									<view class="title">{{ item.name }}</view>
 									<view class="shop">所属门店:{{ item.merchantName }}</view>
+									<view class="scope">适用:{{ item.scopeText || '全部商品适用' }}</view>
+									<view class="scope">特价及活动:{{ getSpecialApplicableText(item) }}</view>
 									<template v-if="item.status != -1">
 										<view class="date" v-if="item.beginTime * 1000 > Date.now()">生效时间:{{ item.beginTime | formatTime('YYYY-MM-DD') }}</view>
 										<view class="date" v-if="item.endTime == 4102416000">有效期:永久可用</view>
@@ -118,9 +124,38 @@ export default {
 		this.reachBottom()
 	},
 	methods: {
+		/**
+		 * 格式化特价及活动商品限制。
+		 * 优先使用后端文案,旧数据缺少文案时按 specialApplicable 兼容展示。
+		 */
+		getSpecialApplicableText(item) {
+			if (item.specialApplicableText) {
+				return item.specialApplicableText
+			}
+			return Number(item.specialApplicable) === 1 ? '特价及活动商品适用' : '特价及活动商品不适用'
+		},
+		/**
+		 * 初始化红包列表,保留原有分页状态。
+		 */
 		async init() {
 			this.getHbList()
 		},
+		/**
+		 * 用接口返回的各状态数量刷新顶部 Tab。
+		 * 字段与 app-mall /hb/list 约定一致:unUse / used / expired / all。
+		 */
+		applyTabCounts(data) {
+			if (!data) {
+				return
+			}
+			this.tabs[0].value = Number(data.unUse) || 0
+			this.tabs[1].value = Number(data.used) || 0
+			this.tabs[2].value = Number(data.expired) || 0
+			this.tabs[3].value = Number(data.all) || 0
+		},
+		/**
+		 * 按当前标签和页码加载红包,成功后交给列表混入合并分页,并刷新状态数量。
+		 */
 		getHbList() {
 			if (this.isRequesting || this.list.finished) {
 				return Promise.resolve(false)
@@ -131,6 +166,7 @@ export default {
 			return getList({ status: status, page: this.list.page }).then(res => {
 				if(res.code == 1){
 					this.completes(res)
+					this.applyTabCounts(res.data)
 				}else{
 					this.list.loading = false
 					this.$msg(res.msg || '获取红包列表失败')
@@ -232,7 +268,8 @@ export default {
 								color:#333;
 								line-height: 1.3;
 							}
-							.shop{
+							.shop,
+							.scope {
 								font-size:26upx;
 								margin-bottom: 8upx;
 								line-height: 1.3;

+ 45 - 2
mallApp/src/pages/home/index.vue

@@ -85,6 +85,12 @@
     </block>
     <!-- 底部导航栏:固定页面底部,首页 Tab 高亮;cart-count 与分类/订单页一致展示角标 -->
     <shop-tab-bar current="home" :account="account" :cart-count="cartBadgeCount" />
+    <!-- 新红包仅提醒一次,用户操作后由组件回写已通知状态 -->
+    <app-hb-arrival
+      :show.sync="hbArrivalShow"
+      :info="hbArrivalInfo"
+      :hb-ids="hbArrivalIds"
+    />
   </view>
 </template>
 
@@ -97,9 +103,11 @@ import HomeNavGrid from '@/components/home/navGrid.vue'
 import HomeActivitySection from '@/components/home/activitySection.vue'
 import HomeGoodsSection from '@/components/home/goodsSection.vue'
 import ShopTabBar from '@/components/shop-tab-bar/index.vue'
+import AppHbArrival from '@/components/module/app-hb-arrival.vue'
 import { getHome } from '@/api/home-page-config'
 import { getHdInfo } from '@/api/hd'
 import { getInfo } from '@/api/shop'
+import { checkArrival } from '@/api/hb'
 
 export default {
   name: 'shopHomeIndex',
@@ -111,7 +119,8 @@ export default {
     HomeNavGrid,
     HomeActivitySection,
     HomeGoodsSection,
-    ShopTabBar
+    ShopTabBar,
+    AppHbArrival
   },
   data() {
     return {
@@ -122,7 +131,12 @@ export default {
       /** 底部 tab 购物车角标数量(本地缓存非响应式,需 onShow 主动刷新) */
       cartBadgeCount: 0,
       /** 当前用户在该店的 xhCustom.id,分享 path 带 inviterCustomId */
-      myCustomId: 0
+      myCustomId: 0,
+      /** 新红包到账提醒状态,由后端未通知记录驱动 */
+      hbArrivalShow: false,
+      hbArrivalInfo: {},
+      hbArrivalIds: [],
+      hbArrivalLoading: false
     }
   },
   created() {
@@ -134,6 +148,10 @@ export default {
   /** 从分类/购物车返回时刷新角标,避免仍显示旧数量 */
   onShow() {
     this.refreshCartBadgeCount()
+    // 首次进入等待 init 刷新门店信息,避免沿用上一家门店缓存查询错红包。
+    if (this.pageData && this.shopInfo && this.shopInfo.id) {
+      this.checkHbArrival()
+    }
   },
   onShareAppMessage() {
     const path = this.appendInviterCustomId('pages/home/index?account=' + this.account)
@@ -244,6 +262,30 @@ export default {
       const sep = path.indexOf('?') >= 0 ? '&' : '?'
       return path + sep + 'inviterCustomId=' + this.myCustomId
     },
+    /**
+     * 查询当前门店的新到账红包。
+     * 必须在门店信息就绪后请求,避免 shopId 缺失导致提醒到其他门店的红包。
+     */
+    checkHbArrival() {
+      if (this.hbArrivalLoading) {
+        return Promise.resolve()
+      }
+      const shopId = Number(this.shopInfo && this.shopInfo.id) || Number(this.hdId) || 0
+      if (shopId <= 0) {
+        return Promise.resolve()
+      }
+      this.hbArrivalLoading = true
+      return checkArrival({ shopId }).then((res) => {
+        const data = res && res.code == 1 ? (res.data || {}) : {}
+        if (Number(data.has) === 1) {
+          this.hbArrivalInfo = data.hb || {}
+          this.hbArrivalIds = Array.isArray(data.hbIds) ? data.hbIds : []
+          this.hbArrivalShow = true
+        }
+      }).catch(() => {}).finally(() => {
+        this.hbArrivalLoading = false
+      })
+    },
     /** 页面初始化:并行拉取首页配置与门店信息(merchantName) */
     init() {
       this.loading = true
@@ -257,6 +299,7 @@ export default {
           if (homeRes.code === 1 && homeRes.data) {
             this.pageData = homeRes.data
           }
+          this.checkHbArrival()
         })
         .finally(() => {
           this.loading = false

+ 858 - 810
mallApp/src/pages/home/recent.vue

@@ -1,810 +1,858 @@
-<template>
-  <view class="page-container">
-    <!-- 未登录状态 -->
-    <block v-if="loginStyle == 0">
-      <!-- 无网络提示条 -->
-      <no-network-bar></no-network-bar>
-      <view class="login-container">
-        <view class="login-content">
-          <text class="login-title">欢迎使用</text>
-          <text class="login-subtitle">登录查看您的花店</text>
-          <button class="login-btn" @click="pageTo({ url: '/pages/login/index?needBack=1' })"> 立即登录 </button>
-        </view>
-      </view>
-    </block>
-
-    <!-- 已登录状态 -->
-    <block v-else>
-      <view class="main-content">
-	      <!-- 无网络提示条 -->
-	      <no-network-bar></no-network-bar>
-
-        <!-- 花店列表 -->
-        <view class="shop-list" v-if="!$util.isEmpty(list.data)">
-          <view
-            class="shop-card"
-            v-for="(item, index) in list.data"
-            :key="item.id"
-          >
-            <!-- 更多选项按钮 -->
-            <view class="more-btn" @click.stop="showMoreOptions(index)">
-              <text class="more-icon">...</text>
-            </view>
-
-            <!-- 更多选项弹出菜单 -->
-            <view
-              class="options-popup"
-              v-if="activePopupIndex === index"
-              @click.stop
-            >
-              <view class="popup-overlay" @click="hideMoreOptions"></view>
-              <view class="popup-content">
-                <view class="popup-item" @click="getLevelChange(item)">
-                  <text>等级变动</text>
-                </view>
-                <view class="popup-item" @click="toRechargeClear(item)">
-                  <text>充值</text>
-                </view>
-                <view class="popup-item" @click="toPay(item)">
-                  <text>付款</text>
-                </view>
-                <view class="popup-item" @click="getRechargeList(item)">
-                  <text>充值记录</text>
-                </view>
-                <view class="popup-item" @click="toGrowth(item)">
-                  <text>成长值记录</text>
-                </view>
-                <view class="popup-item" @click="toIntegral(item)">
-                  <text>积分记录</text>
-                </view>
-                <view class="popup-item" @click="delMyHd(item)">
-                  <text>删除</text>
-                </view>
-                <view class="popup-item cancel" @click="hideMoreOptions()">
-                  <text>取消</text>
-                </view>
-              </view>
-            </view>
-
-            <!-- 店铺基本信息 -->
-            <view class="shop-header">
-              <view class="shop-avatar">
-                <image
-                  class="shop-logo"
-                  :src="item.smallAvatar"
-                  mode="aspectFill"
-                />
-              </view>
-              <view class="shop-info">
-                <view class="shop-name-row">
-                  <text class="shop-name">{{ item.name }}</text>
-                  <view class="member-tag">
-                    <sprite-icon name="huangguan" :size="21" custom-class="member-crown-icon" />
-                    <text class="member-tag-text">{{ getMemberLevelText(item) }}</text>
-                  </view>
-                </view>
-                <text class="shop-address">{{ item.dist }}{{ item.address }}{{ item.floor }}</text>
-              </view>
-            </view>
-
-            <!-- 余额 / 进入店铺 -->
-            <view class="shop-main-actions">
-              <view class="balance-btn" @click.stop="recharge(item)">
-                <sprite-icon name="huashu" :size="40" custom-class="action-icon-slot" />
-                <view class="balance-btn-content">
-                  <text class="balance-btn-label">{{ getBalanceLabel(item) }}</text>
-                  <text class="balance-btn-value" :class="{ negative: Number(item.balance) < 0 }">{{ formatBalanceAmount(item) }}</text>
-                </view>
-              </view>
-              <view
-                class="enter-btn"
-                :class="{ disabled: item.openShop === 0 }"
-                @click.stop="enterShop(item)"
-              >
-                <sprite-icon name="hua" :size="40" custom-class="action-icon-slot" />
-                <text class="enter-btn-text">进入店铺</text>
-              </view>
-            </view>
-
-            <!-- 底部快捷操作:2×2 网格,图标+文字横向排列 -->
-            <view class="shop-util-actions">
-              <view class="util-btn" @click.stop="goMyDividend(item)">
-                <sprite-icon name="dengji-biandong" :size="32" custom-class="util-btn-icon" />
-                <text class="util-btn-text">我的分红</text>
-              </view>
-              <view class="util-btn" @click.stop="getBalanceChange(item)">
-                <sprite-icon name="yue-biandong" :size="32" custom-class="util-btn-icon" />
-                <text class="util-btn-text">余额变动</text>
-              </view>
-              <view class="util-btn" @click.stop="goMemberBenefits(item)">
-                <sprite-icon name="huiyuan-quanyi" :size="32" custom-class="util-btn-icon" />
-                <text class="util-btn-text">会员权益</text>
-              </view>
-              <view class="util-btn" @click.stop="contactService(item)">
-                <sprite-icon name="lianxikefu" :size="32" custom-class="util-btn-icon" />
-                <text class="util-btn-text">联系客服</text>
-              </view>
-            </view>
-          </view>
-        </view>
-
-        <view v-else class="empty-state">
-          <text class="empty-icon">🏪</text>
-          <text class="empty-title">暂无花店</text>
-        </view>
-      </view>
-    </block>
-  </view>
-</template>
-<script>
-import AppSwiper from "@/components/app-swiper";
-import TuiListCell from "@/components/plugin/list-cell";
-import TuiListView from "@/components/plugin/list-view";
-import AppActivilyCoupon from "@/components/module/app-activily-coupon";
-import { getList, delHd } from "@/api/hd";
-import { share } from "@/mixins";
-import AppAvatarModule from "@/components/module/app-avatar";
-import NoNetworkBar from "@/components/no-network-bar.vue";
-import SpriteIcon from "@/components/sprite-icon/index.vue";
-import { mapGetters } from "vuex";
-import { currentInfo } from "@/api/user";
-import { list } from "@/mixins";
-import { TOKEN_STORAGE_KEY } from "@/constant/storageKeys";
-export default {
-  name: "recent",
-  components: {
-    AppAvatarModule,
-    AppSwiper,
-    TuiListView,
-    TuiListCell,
-    AppActivilyCoupon,
-    NoNetworkBar,
-    SpriteIcon,
-  },
-  mixins: [share, list],
-  data() {
-    return {
-      //0没有登录 1有登录
-      loginStyle: 0,
-      activePopupIndex: -1,
-      pageAction: { refresh: 0 },
-      user: { name: "", smallAvatar: "", id: 0 },
-    };
-  },
-  computed: {
-    ...mapGetters({ userInfo: "getUser", loginInfo: "getLoginInfo" }),
-  },
-  watch: {
-    loginInfo(newVal) {
-      if (!this.$util.isEmpty(newVal)) {
-        this.loginStyle = 1;
-      } else {
-        this.loginStyle = 0;
-      }
-    },
-  },
-  onShow() {
-    if (!this.$util.isEmpty(this.loginInfo)) {
-      this.loginStyle = 1;
-    } else {
-      this.loginStyle = 0;
-    }
-    if (this.pageAction.refresh == 1) {
-      this.resetList();
-      this.init();
-      this.pageAction.refresh = 0;
-    }
-  },
-  onPullDownRefresh() {
-    this.resetList();
-    // 重新获取数据
-    this.getMyHdList()
-      .then(() => {
-        uni.stopPullDownRefresh();
-      })
-      .catch(() => {
-        uni.stopPullDownRefresh();
-      });
-  },
-  onReachBottom() {
-    // 滚动到底部加载更多
-    this.toBottom();
-  },
-  methods: {
-    recharge(item) {
-      this.pageTo({
-        url:
-          "/pages/member/recharge?hdId=" + item.id + "&account=" + item.shopId,
-      });
-    },
-    toBottom() {
-      if (!this.list.finished) {
-        this.getMyHdList().then((res) => {
-          uni.stopPullDownRefresh();
-        });
-      } else {
-        uni.stopPullDownRefresh();
-      }
-    },
-    getSettleList(item) {
-      this.pageTo({ url: "/pages/settle/list?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
-    },
-    getLevelChange(item) {
-      this.hideMoreOptions();
-      this.pageTo({ url: "/pages/custom/levelChange?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
-    },
-    /** 我的分红 */
-    goMyDividend(item) {
-      this.pageTo({
-        url: "/pages/custom/myDividend?account=" + item.shopId + "&hdId=" + item.id + "&hdName=" + encodeURIComponent(item.name || "")
-      });
-    },
-    getBalanceChange(item) {
-      this.pageTo({ url: "/pages/balance/changeList?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
-    },
-    getBuyList(item) {
-      this.pageTo({
-        url: "/pages/home/order",
-        type: 4,
-        query: { id: item.id, account: item.shopId, hdId: item.id }
-      });
-    },
-    bug(item) {
-      if(item.openShop == 0){
-        this.$msg('已休店')
-        return
-      }
-      // 进入店铺改为跳转可配置的店铺首页(原花材选购页改由首页内链接进入)
-      this.pageTo({ url: "/pages/home/index?account=" + item.shopId + "&hdId=" + item.id, })
-    },
-    hs(item) {
-      if(item.openShop == 0){
-        this.$msg('已休店')
-        return
-      }
-      this.pageTo({ url: "/pages/home/category?id=" + item.id + "&account=" + item.shopId, })
-    },
-    tj(item) {
-      this.pageTo({
-        url: "/pages/home/mall?id=" + item.id + "&account=" + item.shopId,
-      })
-    },
-    hasDelHd(){
-      this.pageTo({ url: "/pages/hd/hasDel" });
-    },
-    init() {
-      this.getLoginInfo()
-      this.getMyHdList()
-    },
-    getLoginInfo() {
-      currentInfo().then((res) => {
-        if (res.code == 1) {
-          if (res.data.info) {
-            this.user = res.data.info;
-          } else {
-            this.user = { name: "", smallAvatar: "", id: 0 };
-            this.loginStyle = 0;
-            this.$store.commit("setLoginInfo", {});
-            uni.removeStorageSync(TOKEN_STORAGE_KEY);
-          }
-        }
-      });
-    },
-    getMyHdList() {
-      return getList({ page: this.list.page }).then((res) => {
-        this.completes(res);
-        if (this.$util.isEmpty(res.data)) {
-          return false;
-        }
-      });
-    },
-    showMoreOptions(index) {
-      this.activePopupIndex = index;
-    },
-    hideMoreOptions() {
-      this.activePopupIndex = -1;
-    },
-    toPay(item) {
-      this.hideMoreOptions();
-      this.pageTo({
-        url:
-          "/pages/pay/index?account=" +
-          item.shopId +
-          "&hdId=" +
-          item.id +
-          "&hdName=" +
-          item.name +
-          "&store=0&fromType=3",
-      });
-    },
-    getRechargeList(item) {
-      this.hideMoreOptions();
-      this.pageTo({
-        url:
-          "/pages/recharge/list?account=" +
-          item.shopId +
-          "&hdId=" +
-          item.id +
-          "&hdName=" +
-          item.name,
-      });
-    },
-    toRechargeClear(item) {
-      this.hideMoreOptions();
-      this.recharge(item);
-    },
-    toGrowth(item) {
-      this.pageTo({
-        url:
-          "/pages/user/growthList?account=" +
-          item.shopId +
-          "&hdId=" +
-          item.id +
-          "&hdName=" +
-          item.name,
-      });
-    },
-    toIntegral(item) {
-      this.pageTo({
-        url:
-          "/pages/user/integralList?account=" +
-          item.shopId +
-          "&hdId=" +
-          item.id +
-          "&hdName=" +
-          item.name,
-      });
-    },
-    delMyHd(item) {
-      this.hideMoreOptions();
-      this.$util.confirmModal({content:'确认删除该花店?'},() => {
-        delHd({ id: item.id }).then(res => {
-          if (res.code == 1) {
-            this.$msg("删除成功")
-            this.resetList()
-            this.getMyHdList()
-          }
-        });
-      });
-    },
-    /**
-     * 会员等级文案,接口无字段时默认普通会员
-     */
-    getMemberLevelText(item) {
-      return item.levelName || item.memberName || item.memberLevel || "普通会员";
-    },
-    /**
-     * 余额按钮标签:负数为待结,其余为余额
-     */
-    getBalanceLabel(item) {
-      return Number(item.balance) < 0 ? "待结" : "余额";
-    },
-    /**
-     * 余额展示数值
-     */
-    formatBalanceAmount(item) {
-      const balance = Number(item.balance || 0);
-      return parseFloat(Math.abs(balance));
-    },
-    /**
-     * 进入店铺:跳转可配置的店铺首页
-     */
-    enterShop(item) {
-      if (item.openShop == 0) {
-        this.$msg("已休店");
-        return;
-      }
-      this.bug(item);
-    },
-    /**
-     * 会员权益
-     */
-    goMemberBenefits(item) {
-      this.pageTo({
-        url: "/pages/interest/level?account=" + item.shopId + "&hdId=" + item.id
-      });
-    },
-    /**
-     * 联系客服:打开对应门店聊天
-     */
-    contactService(item) {
-      uni.navigateTo({
-        url: "/pages/chat/chatPage",
-        success: (res) => {
-          res.eventChannel.emit("acceptDataFromOpenerPage", {
-            shopId: item.shopId,
-            customId: item.customId || "",
-            name: this.user.name || "",
-            avatar: this.user.smallAvatar || "",
-            chatPerson: item.name
-          });
-        }
-      });
-    }
-  },
-};
-</script>
-<style lang="scss" scoped>
-page {
-  height: 100%;
-  background-color: #f3f4f6;
-}
-
-.page-container {
-  min-height: 100vh;
-  background-color: #f3f4f6;
-  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
-}
-
-// 登录页面样式
-.login-container {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  min-height: 70vh;
-  padding: 0upx 40upx 40upx 40upx;
-
-  .login-content {
-    text-align: center;
-
-    .login-title {
-      display: block;
-      font-size: 48upx;
-      font-weight: 600;
-      color: #333;
-      margin-bottom: 16upx;
-    }
-
-    .login-subtitle {
-      display: block;
-      font-size: 34upx;
-      color: #666;
-      margin-top:50upx;
-      margin-bottom: 60upx;
-    }
-
-    .login-btn {
-      background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
-      color: white;
-      border: none;
-      border-radius: 50upx;
-      padding: 24upx 60upx;
-      font-size: 32upx;
-      font-weight: 500;
-      box-shadow: 0 8upx 20upx rgba(255, 107, 107, 0.3);
-
-      &:active {
-        box-shadow: 0 4upx 12upx rgba(255, 107, 107, 0.4);
-      }
-    }
-  }
-}
-
-// 主要内容区域
-.main-content {
-  width: 100%;
-}
-
-// 花店列表
-.shop-list {
-  padding: 20upx 24upx 40upx;
-}
-
-// 花店卡片
-.shop-card {
-  background: #ffffff;
-  border-radius: 24upx;
-  margin-bottom: 24upx;
-  padding: 28upx 28upx 24upx;
-  position: relative;
-  box-shadow: 0 8upx 24upx rgba(15, 23, 42, 0.06);
-
-  .more-btn {
-    position: absolute;
-    top: 24upx;
-    right: 24upx;
-    width: 48upx;
-    height: 48upx;
-    display: flex;
-    justify-content: center;
-    border-radius: 24upx;
-    background: #f3f4f6;
-    z-index: 2;
-
-    .more-icon {
-      font-size: 32upx;
-      color: #9ca3af;
-      font-weight: bold;
-      line-height: 1;
-      letter-spacing: 2upx;
-    }
-  }
-
-  .shop-header {
-    display: flex;
-    align-items: center;
-    padding-right: 56upx;
-    margin-bottom: 24upx;
-
-    .shop-avatar {
-      margin-right: 20upx;
-      flex-shrink: 0;
-
-      .shop-logo {
-        width: 96upx;
-        height: 96upx;
-        border-radius: 48upx;
-        background-color: #f5f5f5;
-      }
-    }
-
-    .shop-info {
-      flex: 1;
-      min-width: 0;
-
-      .shop-name-row {
-        display: flex;
-        align-items: center;
-        margin-bottom: 10upx;
-      }
-
-      /* custom-class 在子组件根节点,需 deep 才能写间距 */
-      .shop-name-row ::v-deep .shop-name-icon {
-        margin-right: 8upx;
-        flex-shrink: 0;
-      }
-
-      .member-tag ::v-deep .member-crown-icon {
-        margin-right: 4upx;
-	    margin-botom: 14upx;
-        flex-shrink: 0;
-      }
-
-      .shop-name {
-        max-width: 280upx;
-        color: #111827;
-        font-size: 34upx;
-        font-weight: 700;
-        line-height: 1.2;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-
-      .member-tag {
-        margin-left: 12upx;
-        padding: 4upx 14upx;
-        border-radius: 20upx;
-        background: #eef9f0;
-        flex-shrink: 0;
-        display: flex;
-        align-items: center;
-      }
-
-      .member-tag-text {
-        color: #1f7a3d;
-        font-size: 22upx;
-        font-weight: 600;
-        line-height: 1.2;
-      }
-
-      .shop-address {
-        display: block;
-        color: #9ca3af;
-        font-size: 24upx;
-        line-height: 1.4;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-    }
-  }
-
-  .shop-main-actions {
-    display: flex;
-    margin-bottom: 20upx;
-  }
-
-  .balance-btn,
-  .enter-btn {
-    flex: 1;
-    min-height: 96upx;
-    border-radius: 16upx;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    box-sizing: border-box;
-  }
-
-  .balance-btn {
-    margin-right: 16upx;
-    background: #ffeef5;
-  }
-
-  .enter-btn {
-    background: #eef9f0;
-
-    &.disabled {
-      opacity: 0.55;
-    }
-  }
-
-  .balance-btn ::v-deep .action-icon-slot,
-  .enter-btn ::v-deep .action-icon-slot {
-    margin-right: 10upx;
-    flex-shrink: 0;
-  }
-
-  .balance-btn-content {
-    display: flex;
-    align-items: baseline;
-  }
-
-  .balance-btn-label {
-    color: #374151;
-    font-size: 28upx;
-    font-weight: 600;
-    margin-right: 8upx;
-  }
-
-  .balance-btn-value {
-    color: #d63384;
-    font-size: 34upx;
-    font-weight: 700;
-    line-height: 1;
-
-    &.negative {
-      color: #ef4444;
-    }
-  }
-
-  .enter-btn-text {
-    color: #1f7a3d;
-    font-size: 30upx;
-    font-weight: 700;
-  }
-
-  .shop-util-actions {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: nowrap;
-  }
-
-  /* 快捷按钮:同一排,图标左、文字右 */
-  .util-btn {
-    flex: 1;
-    min-width: 0;
-    margin-right: 12upx;
-    padding: 14upx 8upx;
-    box-sizing: border-box;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: center;
-    background: #f5f6f8;
-    border: 1upx solid #e5e7eb;
-    border-radius: 12upx;
-
-    &:last-child {
-      margin-right: 0;
-    }
-  }
-
-  .util-btn ::v-deep .util-btn-icon {
-    margin-right: 6upx;
-    flex-shrink: 0;
-  }
-
-  .util-btn-text {
-    flex-shrink: 1;
-    min-width: 0;
-    color: #6b7280;
-    font-size: 20upx;
-    line-height: 1.2;
-    text-align: left;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-  }
-}
-
-// 弹出菜单
-.options-popup {
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100vw;
-  height: 100vh;
-  z-index: 1000;
-
-  .popup-overlay {
-    position: absolute;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    background-color: rgba(0, 0, 0, 0.4);
-  }
-
-  .popup-content {
-    position: absolute;
-    top: 50%;
-    left: 50%;
-    transform: translate(-50%, -50%);
-    background-color: white;
-    border-radius: 20upx;
-    padding: 0;
-    min-width: 500upx;
-    box-shadow: 0 20upx 40upx rgba(0, 0, 0, 0.3);
-    overflow: hidden;
-
-    .popup-item {
-      padding: 40upx 50upx;
-      border-bottom: 1upx solid #f0f0f0;
-      text-align: center;
-      transition: all 0.2s ease;
-
-      &:last-child {
-        border-bottom: none;
-      }
-
-      /* &:active {
-        background-color: #f8f9fa;
-      } */
-
-      &.cancel {
-        color: #999;
-      }
-
-      text {
-        color: #333;
-        font-size: 36upx;
-        font-weight: 500;
-      }
-    }
-  }
-}
-
-// 空状态
-.empty-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  padding: 100upx 40upx;
-  text-align: center;
-
-  .empty-icon {
-    font-size: 120upx;
-    margin-bottom: 30upx;
-  }
-
-  .empty-title {
-    font-size: 36upx;
-    font-weight: 600;
-    color: #333;
-    margin-bottom: 12upx;
-  }
-
-  .empty-subtitle {
-    font-size: 28upx;
-    color: #999;
-  }
-}
-
-// 动画效果
-@keyframes pulse {
-  0% {
-    transform: scale(1);
-    opacity: 1;
-  }
-  50% {
-    transform: scale(1.2);
-    opacity: 0.8;
-  }
-  100% {
-    transform: scale(1);
-    opacity: 1;
-  }
-}
-</style>
+<!--
+  个人首页
+  已登录用户在这里管理花店,并在花店列表加载后接收尚未通知的红包到账提醒。
+-->
+<template>
+  <view class="page-container">
+    <!-- 未登录状态 -->
+    <block v-if="loginStyle == 0">
+      <!-- 无网络提示条 -->
+      <no-network-bar></no-network-bar>
+      <view class="login-container">
+        <view class="login-content">
+          <text class="login-title">欢迎使用</text>
+          <text class="login-subtitle">登录查看您的花店</text>
+          <button class="login-btn" @click="pageTo({ url: '/pages/login/index?needBack=1' })"> 立即登录 </button>
+        </view>
+      </view>
+    </block>
+
+    <!-- 已登录状态 -->
+    <block v-else>
+      <view class="main-content">
+	      <!-- 无网络提示条 -->
+	      <no-network-bar></no-network-bar>
+
+        <!-- 花店列表 -->
+        <view class="shop-list" v-if="!$util.isEmpty(list.data)">
+          <view
+            class="shop-card"
+            v-for="(item, index) in list.data"
+            :key="item.id"
+          >
+            <!-- 更多选项按钮 -->
+            <view class="more-btn" @click.stop="showMoreOptions(index)">
+              <text class="more-icon">...</text>
+            </view>
+
+            <!-- 更多选项弹出菜单 -->
+            <view
+              class="options-popup"
+              v-if="activePopupIndex === index"
+              @click.stop
+            >
+              <view class="popup-overlay" @click="hideMoreOptions"></view>
+              <view class="popup-content">
+                <view class="popup-item" @click="getLevelChange(item)">
+                  <text>等级变动</text>
+                </view>
+                <view class="popup-item" @click="toRechargeClear(item)">
+                  <text>充值</text>
+                </view>
+                <view class="popup-item" @click="toPay(item)">
+                  <text>付款</text>
+                </view>
+                <view class="popup-item" @click="getRechargeList(item)">
+                  <text>充值记录</text>
+                </view>
+                <view class="popup-item" @click="toGrowth(item)">
+                  <text>成长值记录</text>
+                </view>
+                <view class="popup-item" @click="toIntegral(item)">
+                  <text>积分记录</text>
+                </view>
+                <view class="popup-item" @click="delMyHd(item)">
+                  <text>删除</text>
+                </view>
+                <view class="popup-item cancel" @click="hideMoreOptions()">
+                  <text>取消</text>
+                </view>
+              </view>
+            </view>
+
+            <!-- 店铺基本信息 -->
+            <view class="shop-header">
+              <view class="shop-avatar">
+                <image
+                  class="shop-logo"
+                  :src="item.smallAvatar"
+                  mode="aspectFill"
+                />
+              </view>
+              <view class="shop-info">
+                <view class="shop-name-row">
+                  <text class="shop-name">{{ item.name }}</text>
+                  <view class="member-tag">
+                    <sprite-icon name="huangguan" :size="21" custom-class="member-crown-icon" />
+                    <text class="member-tag-text">{{ getMemberLevelText(item) }}</text>
+                  </view>
+                </view>
+                <text class="shop-address">{{ item.dist }}{{ item.address }}{{ item.floor }}</text>
+              </view>
+            </view>
+
+            <!-- 余额 / 进入店铺 -->
+            <view class="shop-main-actions">
+              <view class="balance-btn" @click.stop="recharge(item)">
+                <sprite-icon name="huashu" :size="40" custom-class="action-icon-slot" />
+                <view class="balance-btn-content">
+                  <text class="balance-btn-label">{{ getBalanceLabel(item) }}</text>
+                  <text class="balance-btn-value" :class="{ negative: Number(item.balance) < 0 }">{{ formatBalanceAmount(item) }}</text>
+                </view>
+              </view>
+              <view
+                class="enter-btn"
+                :class="{ disabled: item.openShop === 0 }"
+                @click.stop="enterShop(item)"
+              >
+                <sprite-icon name="hua" :size="40" custom-class="action-icon-slot" />
+                <text class="enter-btn-text">进入店铺</text>
+              </view>
+            </view>
+
+            <!-- 底部快捷操作:2×2 网格,图标+文字横向排列 -->
+            <view class="shop-util-actions">
+              <view class="util-btn" @click.stop="goMyDividend(item)">
+                <sprite-icon name="dengji-biandong" :size="32" custom-class="util-btn-icon" />
+                <text class="util-btn-text">我的分红</text>
+              </view>
+              <view class="util-btn" @click.stop="getBalanceChange(item)">
+                <sprite-icon name="yue-biandong" :size="32" custom-class="util-btn-icon" />
+                <text class="util-btn-text">余额变动</text>
+              </view>
+              <view class="util-btn" @click.stop="goMemberBenefits(item)">
+                <sprite-icon name="huiyuan-quanyi" :size="32" custom-class="util-btn-icon" />
+                <text class="util-btn-text">会员权益</text>
+              </view>
+              <view class="util-btn" @click.stop="contactService(item)">
+                <sprite-icon name="lianxikefu" :size="32" custom-class="util-btn-icon" />
+                <text class="util-btn-text">联系客服</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <view v-else class="empty-state">
+          <text class="empty-icon">🏪</text>
+          <text class="empty-title">暂无花店</text>
+        </view>
+      </view>
+    </block>
+    <!-- 个人首页未固定门店,由后端按当前用户上下文返回待提醒红包 -->
+    <app-hb-arrival
+      :show.sync="hbArrivalShow"
+      :info="hbArrivalInfo"
+      :hb-ids="hbArrivalIds"
+    />
+  </view>
+</template>
+<script>
+import AppSwiper from "@/components/app-swiper";
+import TuiListCell from "@/components/plugin/list-cell";
+import TuiListView from "@/components/plugin/list-view";
+import AppHbArrival from "@/components/module/app-hb-arrival.vue";
+import { getList, delHd } from "@/api/hd";
+import { checkArrival } from "@/api/hb";
+import { share } from "@/mixins";
+import AppAvatarModule from "@/components/module/app-avatar";
+import NoNetworkBar from "@/components/no-network-bar.vue";
+import SpriteIcon from "@/components/sprite-icon/index.vue";
+import { mapGetters } from "vuex";
+import { currentInfo } from "@/api/user";
+import { list } from "@/mixins";
+import { TOKEN_STORAGE_KEY } from "@/constant/storageKeys";
+export default {
+  name: "recent",
+  components: {
+    AppAvatarModule,
+    AppSwiper,
+    TuiListView,
+    TuiListCell,
+    AppHbArrival,
+    NoNetworkBar,
+    SpriteIcon,
+  },
+  mixins: [share, list],
+  data() {
+    return {
+      //0没有登录 1有登录
+      loginStyle: 0,
+      activePopupIndex: -1,
+      pageAction: { refresh: 0 },
+      user: { name: "", smallAvatar: "", id: 0 },
+      /** 新到账红包提醒状态,用户操作后由弹框组件标记已通知 */
+      hbArrivalShow: false,
+      hbArrivalInfo: {},
+      hbArrivalIds: [],
+      hbArrivalLoading: false,
+    };
+  },
+  computed: {
+    ...mapGetters({ userInfo: "getUser", loginInfo: "getLoginInfo" }),
+  },
+  watch: {
+    loginInfo(newVal) {
+      if (!this.$util.isEmpty(newVal)) {
+        this.loginStyle = 1;
+      } else {
+        this.loginStyle = 0;
+      }
+    },
+  },
+  onShow() {
+    if (!this.$util.isEmpty(this.loginInfo)) {
+      this.loginStyle = 1;
+    } else {
+      this.loginStyle = 0;
+    }
+    if (this.pageAction.refresh == 1) {
+      this.resetList();
+      this.init();
+      this.pageAction.refresh = 0;
+    }
+    if (this.loginStyle == 1 && !this.$util.isEmpty(this.list.data)) {
+      this.checkHbArrival();
+    }
+  },
+  onPullDownRefresh() {
+    this.resetList();
+    // 重新获取数据
+    this.getMyHdList()
+      .then(() => {
+        uni.stopPullDownRefresh();
+      })
+      .catch(() => {
+        uni.stopPullDownRefresh();
+      });
+  },
+  onReachBottom() {
+    // 滚动到底部加载更多
+    this.toBottom();
+  },
+  methods: {
+    recharge(item) {
+      this.pageTo({
+        url:
+          "/pages/member/recharge?hdId=" + item.id + "&account=" + item.shopId,
+      });
+    },
+    toBottom() {
+      if (!this.list.finished) {
+        this.getMyHdList().then((res) => {
+          uni.stopPullDownRefresh();
+        });
+      } else {
+        uni.stopPullDownRefresh();
+      }
+    },
+    getSettleList(item) {
+      this.pageTo({ url: "/pages/settle/list?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
+    },
+    getLevelChange(item) {
+      this.hideMoreOptions();
+      this.pageTo({ url: "/pages/custom/levelChange?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
+    },
+    /** 我的分红 */
+    goMyDividend(item) {
+      this.pageTo({
+        url: "/pages/custom/myDividend?account=" + item.shopId + "&hdId=" + item.id + "&hdName=" + encodeURIComponent(item.name || "")
+      });
+    },
+    getBalanceChange(item) {
+      this.pageTo({ url: "/pages/balance/changeList?id=" + item.id + "&account=" + item.shopId+"&hdId="+item.id, })
+    },
+    getBuyList(item) {
+      this.pageTo({
+        url: "/pages/home/order",
+        type: 4,
+        query: { id: item.id, account: item.shopId, hdId: item.id }
+      });
+    },
+    bug(item) {
+      if(item.openShop == 0){
+        this.$msg('已休店')
+        return
+      }
+      // 进入店铺改为跳转可配置的店铺首页(原花材选购页改由首页内链接进入)
+      this.pageTo({ url: "/pages/home/index?account=" + item.shopId + "&hdId=" + item.id, })
+    },
+    hs(item) {
+      if(item.openShop == 0){
+        this.$msg('已休店')
+        return
+      }
+      this.pageTo({ url: "/pages/home/category?id=" + item.id + "&account=" + item.shopId, })
+    },
+    tj(item) {
+      this.pageTo({
+        url: "/pages/home/mall?id=" + item.id + "&account=" + item.shopId,
+      })
+    },
+    hasDelHd(){
+      this.pageTo({ url: "/pages/hd/hasDel" });
+    },
+    init() {
+      this.getLoginInfo()
+      this.getMyHdList()
+    },
+    /**
+     * 刷新登录用户信息。
+     * 用户接口和花店列表并行返回时,在两者都就绪后补触发红包检测,避免竞态漏掉提醒。
+     */
+    getLoginInfo() {
+      return currentInfo().then((res) => {
+        if (res.code == 1) {
+          if (res.data.info) {
+            this.user = res.data.info;
+            this.loginStyle = 1;
+            if (!this.$util.isEmpty(this.list.data)) {
+              this.checkHbArrival();
+            }
+          } else {
+            this.user = { name: "", smallAvatar: "", id: 0 };
+            this.loginStyle = 0;
+            this.$store.commit("setLoginInfo", {});
+            uni.removeStorageSync(TOKEN_STORAGE_KEY);
+          }
+        }
+      });
+    },
+    getMyHdList() {
+      return getList({ page: this.list.page }).then((res) => {
+        this.completes(res);
+        if (this.$util.isEmpty(res.data)) {
+          return false;
+        }
+        this.checkHbArrival();
+      });
+    },
+    /**
+     * 花店列表加载完成后查询新红包。
+     * 个人首页没有唯一当前门店,因此不传 shopId,由后端选择当前用户待提醒记录。
+     */
+    checkHbArrival() {
+      if (this.loginStyle != 1 || this.hbArrivalLoading) {
+        return Promise.resolve();
+      }
+      this.hbArrivalLoading = true;
+      return checkArrival({}).then((res) => {
+        const data = res && res.code == 1 ? (res.data || {}) : {};
+        if (Number(data.has) === 1) {
+          this.hbArrivalInfo = data.hb || {};
+          this.hbArrivalIds = Array.isArray(data.hbIds) ? data.hbIds : [];
+          this.hbArrivalShow = true;
+        }
+      }).catch(() => {}).finally(() => {
+        this.hbArrivalLoading = false;
+      });
+    },
+    showMoreOptions(index) {
+      this.activePopupIndex = index;
+    },
+    hideMoreOptions() {
+      this.activePopupIndex = -1;
+    },
+    toPay(item) {
+      this.hideMoreOptions();
+      this.pageTo({
+        url:
+          "/pages/pay/index?account=" +
+          item.shopId +
+          "&hdId=" +
+          item.id +
+          "&hdName=" +
+          item.name +
+          "&store=0&fromType=3",
+      });
+    },
+    getRechargeList(item) {
+      this.hideMoreOptions();
+      this.pageTo({
+        url:
+          "/pages/recharge/list?account=" +
+          item.shopId +
+          "&hdId=" +
+          item.id +
+          "&hdName=" +
+          item.name,
+      });
+    },
+    toRechargeClear(item) {
+      this.hideMoreOptions();
+      this.recharge(item);
+    },
+    toGrowth(item) {
+      this.pageTo({
+        url:
+          "/pages/user/growthList?account=" +
+          item.shopId +
+          "&hdId=" +
+          item.id +
+          "&hdName=" +
+          item.name,
+      });
+    },
+    toIntegral(item) {
+      this.pageTo({
+        url:
+          "/pages/user/integralList?account=" +
+          item.shopId +
+          "&hdId=" +
+          item.id +
+          "&hdName=" +
+          item.name,
+      });
+    },
+    delMyHd(item) {
+      this.hideMoreOptions();
+      this.$util.confirmModal({content:'确认删除该花店?'},() => {
+        delHd({ id: item.id }).then(res => {
+          if (res.code == 1) {
+            this.$msg("删除成功")
+            this.resetList()
+            this.getMyHdList()
+          }
+        });
+      });
+    },
+    /**
+     * 会员等级文案,接口无字段时默认普通会员
+     */
+    getMemberLevelText(item) {
+      return item.levelName || item.memberName || item.memberLevel || "普通会员";
+    },
+    /**
+     * 余额按钮标签:负数为待结,其余为余额
+     */
+    getBalanceLabel(item) {
+      return Number(item.balance) < 0 ? "待结" : "余额";
+    },
+    /**
+     * 余额展示数值
+     */
+    formatBalanceAmount(item) {
+      const balance = Number(item.balance || 0);
+      return parseFloat(Math.abs(balance));
+    },
+    /**
+     * 进入店铺:跳转可配置的店铺首页
+     */
+    enterShop(item) {
+      if (item.openShop == 0) {
+        this.$msg("已休店");
+        return;
+      }
+      this.bug(item);
+    },
+    /**
+     * 会员权益
+     */
+    goMemberBenefits(item) {
+      this.pageTo({
+        url: "/pages/interest/level?account=" + item.shopId + "&hdId=" + item.id
+      });
+    },
+    /**
+     * 联系客服:打开对应门店聊天
+     */
+    contactService(item) {
+      uni.navigateTo({
+        url: "/pages/chat/chatPage",
+        success: (res) => {
+          res.eventChannel.emit("acceptDataFromOpenerPage", {
+            shopId: item.shopId,
+            customId: item.customId || "",
+            name: this.user.name || "",
+            avatar: this.user.smallAvatar || "",
+            chatPerson: item.name
+          });
+        }
+      });
+    }
+  },
+};
+</script>
+<style lang="scss" scoped>
+page {
+  height: 100%;
+  background-color: #f3f4f6;
+}
+
+.page-container {
+  min-height: 100vh;
+  background-color: #f3f4f6;
+  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
+}
+
+// 登录页面样式
+.login-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 70vh;
+  padding: 0upx 40upx 40upx 40upx;
+
+  .login-content {
+    text-align: center;
+
+    .login-title {
+      display: block;
+      font-size: 48upx;
+      font-weight: 600;
+      color: #333;
+      margin-bottom: 16upx;
+    }
+
+    .login-subtitle {
+      display: block;
+      font-size: 34upx;
+      color: #666;
+      margin-top:50upx;
+      margin-bottom: 60upx;
+    }
+
+    .login-btn {
+      background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
+      color: white;
+      border: none;
+      border-radius: 50upx;
+      padding: 24upx 60upx;
+      font-size: 32upx;
+      font-weight: 500;
+      box-shadow: 0 8upx 20upx rgba(255, 107, 107, 0.3);
+
+      &:active {
+        box-shadow: 0 4upx 12upx rgba(255, 107, 107, 0.4);
+      }
+    }
+  }
+}
+
+// 主要内容区域
+.main-content {
+  width: 100%;
+}
+
+// 花店列表
+.shop-list {
+  padding: 20upx 24upx 40upx;
+}
+
+// 花店卡片
+.shop-card {
+  background: #ffffff;
+  border-radius: 24upx;
+  margin-bottom: 24upx;
+  padding: 28upx 28upx 24upx;
+  position: relative;
+  box-shadow: 0 8upx 24upx rgba(15, 23, 42, 0.06);
+
+  .more-btn {
+    position: absolute;
+    top: 24upx;
+    right: 24upx;
+    width: 48upx;
+    height: 48upx;
+    display: flex;
+    justify-content: center;
+    border-radius: 24upx;
+    background: #f3f4f6;
+    z-index: 2;
+
+    .more-icon {
+      font-size: 32upx;
+      color: #9ca3af;
+      font-weight: bold;
+      line-height: 1;
+      letter-spacing: 2upx;
+    }
+  }
+
+  .shop-header {
+    display: flex;
+    align-items: center;
+    padding-right: 56upx;
+    margin-bottom: 24upx;
+
+    .shop-avatar {
+      margin-right: 20upx;
+      flex-shrink: 0;
+
+      .shop-logo {
+        width: 96upx;
+        height: 96upx;
+        border-radius: 48upx;
+        background-color: #f5f5f5;
+      }
+    }
+
+    .shop-info {
+      flex: 1;
+      min-width: 0;
+
+      .shop-name-row {
+        display: flex;
+        align-items: center;
+        margin-bottom: 10upx;
+      }
+
+      /* custom-class 在子组件根节点,需 deep 才能写间距 */
+      .shop-name-row ::v-deep .shop-name-icon {
+        margin-right: 8upx;
+        flex-shrink: 0;
+      }
+
+      .member-tag ::v-deep .member-crown-icon {
+        margin-right: 4upx;
+	    margin-botom: 14upx;
+        flex-shrink: 0;
+      }
+
+      .shop-name {
+        max-width: 280upx;
+        color: #111827;
+        font-size: 34upx;
+        font-weight: 700;
+        line-height: 1.2;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+
+      .member-tag {
+        margin-left: 12upx;
+        padding: 4upx 14upx;
+        border-radius: 20upx;
+        background: #eef9f0;
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+      }
+
+      .member-tag-text {
+        color: #1f7a3d;
+        font-size: 22upx;
+        font-weight: 600;
+        line-height: 1.2;
+      }
+
+      .shop-address {
+        display: block;
+        color: #9ca3af;
+        font-size: 24upx;
+        line-height: 1.4;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+  }
+
+  .shop-main-actions {
+    display: flex;
+    margin-bottom: 20upx;
+  }
+
+  .balance-btn,
+  .enter-btn {
+    flex: 1;
+    min-height: 96upx;
+    border-radius: 16upx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-sizing: border-box;
+  }
+
+  .balance-btn {
+    margin-right: 16upx;
+    background: #ffeef5;
+  }
+
+  .enter-btn {
+    background: #eef9f0;
+
+    &.disabled {
+      opacity: 0.55;
+    }
+  }
+
+  .balance-btn ::v-deep .action-icon-slot,
+  .enter-btn ::v-deep .action-icon-slot {
+    margin-right: 10upx;
+    flex-shrink: 0;
+  }
+
+  .balance-btn-content {
+    display: flex;
+    align-items: baseline;
+  }
+
+  .balance-btn-label {
+    color: #374151;
+    font-size: 28upx;
+    font-weight: 600;
+    margin-right: 8upx;
+  }
+
+  .balance-btn-value {
+    color: #d63384;
+    font-size: 34upx;
+    font-weight: 700;
+    line-height: 1;
+
+    &.negative {
+      color: #ef4444;
+    }
+  }
+
+  .enter-btn-text {
+    color: #1f7a3d;
+    font-size: 30upx;
+    font-weight: 700;
+  }
+
+  .shop-util-actions {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: nowrap;
+  }
+
+  /* 快捷按钮:同一排,图标左、文字右 */
+  .util-btn {
+    flex: 1;
+    min-width: 0;
+    margin-right: 12upx;
+    padding: 14upx 8upx;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: center;
+    background: #f5f6f8;
+    border: 1upx solid #e5e7eb;
+    border-radius: 12upx;
+
+    &:last-child {
+      margin-right: 0;
+    }
+  }
+
+  .util-btn ::v-deep .util-btn-icon {
+    margin-right: 6upx;
+    flex-shrink: 0;
+  }
+
+  .util-btn-text {
+    flex-shrink: 1;
+    min-width: 0;
+    color: #6b7280;
+    font-size: 20upx;
+    line-height: 1.2;
+    text-align: left;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+// 弹出菜单
+.options-popup {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 1000;
+
+  .popup-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.4);
+  }
+
+  .popup-content {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background-color: white;
+    border-radius: 20upx;
+    padding: 0;
+    min-width: 500upx;
+    box-shadow: 0 20upx 40upx rgba(0, 0, 0, 0.3);
+    overflow: hidden;
+
+    .popup-item {
+      padding: 40upx 50upx;
+      border-bottom: 1upx solid #f0f0f0;
+      text-align: center;
+      transition: all 0.2s ease;
+
+      &:last-child {
+        border-bottom: none;
+      }
+
+      /* &:active {
+        background-color: #f8f9fa;
+      } */
+
+      &.cancel {
+        color: #999;
+      }
+
+      text {
+        color: #333;
+        font-size: 36upx;
+        font-weight: 500;
+      }
+    }
+  }
+}
+
+// 空状态
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 100upx 40upx;
+  text-align: center;
+
+  .empty-icon {
+    font-size: 120upx;
+    margin-bottom: 30upx;
+  }
+
+  .empty-title {
+    font-size: 36upx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 12upx;
+  }
+
+  .empty-subtitle {
+    font-size: 28upx;
+    color: #999;
+  }
+}
+
+// 动画效果
+@keyframes pulse {
+  0% {
+    transform: scale(1);
+    opacity: 1;
+  }
+  50% {
+    transform: scale(1.2);
+    opacity: 0.8;
+  }
+  100% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+</style>

+ 1 - 1
mallApp/src/pages/home/user.vue

@@ -127,7 +127,7 @@ export default {
       ],
       /** 服务菜单 */
       serviceItems: [
-        { key: "coupon", name: "优惠券", icon: "youhuiquan-yellow", iconSize: 40, url: "/pages/hb/list" },
+        { key: "coupon", name: "我的红包", icon: "youhuiquan-yellow", iconSize: 40, url: "/pages/hb/list" },
         { key: "addressNew", name: "收货地址", iconfont: "iconditu", url: "/pages/user/address/list" },
         { key: "hasDel", name: "已删花店", icon: "dianpu-fill", iconSize: 40, url: "/pages/hd/hasDel" },
         { key: "logout", name: "退出登录", icon: "tuichu", iconSize: 40, action: "logout" }

+ 129 - 0
mallApp/src/utils/hbScope.js

@@ -0,0 +1,129 @@
+/**
+ * 红包适用范围计算工具
+ * 结算页与红包选择组件共用同一套范围、活动商品过滤和门槛判断,避免两处可用状态不一致。
+ */
+
+/**
+ * 将后端逗号分隔或数组形式的适用 id 转为字符串集合。
+ * 空值返回空集合,表示定向红包没有可匹配商品。
+ */
+export function parseHbScopeIds(scopeValue) {
+	if (Array.isArray(scopeValue)) {
+		return scopeValue.map((id) => String(id)).filter(Boolean)
+	}
+	if (scopeValue === null || scopeValue === undefined || scopeValue === '') {
+		return []
+	}
+	return String(scopeValue)
+		.split(',')
+		.map((id) => id.trim())
+		.filter(Boolean)
+}
+
+/**
+ * 判断接口布尔标记是否开启。
+ * 兼容数字、字符串和布尔值,避免字符串 "0" 被 JavaScript 当作 true。
+ */
+function isEnabledFlag(value) {
+	if (value === true || value === 1 || value === '1') {
+		return true
+	}
+	if (typeof value === 'string') {
+		return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
+	}
+	return Number(value) > 0
+}
+
+/**
+ * 判断购物车行是否属于特价或活动商品。
+ * 红包 specialApplicable 不开启时,这些行不计入适用金额。
+ */
+export function isHbSpecialOrActivityItem(item) {
+	if (!item) {
+		return false
+	}
+	return isEnabledFlag(item.isSpecial)
+		|| isEnabledFlag(item.isActivity)
+		|| isEnabledFlag(item.seckill)
+		|| item.activityType === 'seckill'
+}
+
+/**
+ * 获取购物车行金额。
+ * 结算页应优先传 hbLineAmount(由自身价格引擎计算);其余字段仅供通用组件兼容旧调用方。
+ */
+export function getHbItemAmount(item) {
+	if (!item) {
+		return 0
+	}
+	const exactAmount = item.hbLineAmount
+	if (exactAmount !== undefined && exactAmount !== null && exactAmount !== '') {
+		return Number(exactAmount) || 0
+	}
+	if (item.totalPrice !== undefined && item.totalPrice !== null && item.totalPrice !== '') {
+		return Number(item.totalPrice) || 0
+	}
+	if (item.lineAmount !== undefined && item.lineAmount !== null && item.lineAmount !== '') {
+		return Number(item.lineAmount) || 0
+	}
+	const bigCount = Number(item.bigCount !== undefined ? item.bigCount : item.num) || 0
+	const smallCount = Number(item.smallCount) || 0
+	const bigPrice = Number(item.price !== undefined ? item.price : item.bigPrice) || 0
+	const smallPrice = Number(item.smallPrice) || 0
+	return bigCount * bigPrice + smallCount * smallPrice
+}
+
+/**
+ * 判断商品行是否命中红包定向范围。
+ * scopeType=2 匹配分类,scopeType=3 匹配商品;兼容结算列表历史 classId 字段。
+ */
+function isItemInHbScope(item, scopeType, scopeIds) {
+	const candidates = scopeType === 2
+		? [item.categoryId, item.classId]
+		: [item.goodsId, item.productId, item.id]
+	return candidates.some((id) => {
+		if (id === null || id === undefined || id === '') {
+			return false
+		}
+		return scopeIds.indexOf(String(id)) >= 0
+	})
+}
+
+/**
+ * 计算红包当前可计入门槛的商品金额。
+ * 全场红包使用整单商品金额;分类/商品红包仅累加命中行,并按 specialApplicable 排除活动行。
+ */
+export function getHbApplicableAmount(hb, productList, totalOrderAmount) {
+	const item = hb || {}
+	const scopeType = Number(item.scopeType) || 1
+	const list = Array.isArray(productList) ? productList : []
+	const allowSpecial = Number(item.specialApplicable) === 1
+	const scopeIds = parseHbScopeIds(item.scopeValue)
+
+	if (scopeType === 1 && allowSpecial) {
+		return Math.round((Number(totalOrderAmount) || 0) * 100) / 100
+	}
+
+	let amount = 0
+	list.forEach((product) => {
+		if (!allowSpecial && isHbSpecialOrActivityItem(product)) {
+			return
+		}
+		if (scopeType !== 1 && !isItemInHbScope(product, scopeType, scopeIds)) {
+			return
+		}
+		amount += getHbItemAmount(product)
+	})
+	return Math.round(amount * 100) / 100
+}
+
+/**
+ * 判断红包是否达到最低消费门槛。
+ * 返回值只代表前端可选状态,最终优惠资格仍由后端下单时校验。
+ */
+export function isHbAvailable(hb, productList, totalOrderAmount) {
+	const applicableAmount = getHbApplicableAmount(hb, productList, totalOrderAmount)
+	const minConsume = Number(hb && hb.minConsume) || 0
+	// 定向红包即使无门槛,也必须至少命中一项有金额的商品。
+	return applicableAmount > 0 && applicableAmount >= minConsume
+}

Some files were not shown because too many files changed in this diff