Procházet zdrojové kódy

智能柜项目提交

skyline před 2 měsíci
rodič
revize
fa3c31abaa
26 změnil soubory, kde provedl 1062 přidání a 203 odebrání
  1. 2 2
      haha-admin-web/src/api/activity.ts
  2. 7 7
      haha-admin-web/src/views/marketing/activity/index.vue
  3. 357 170
      haha-admin-web/src/views/marketing/activity/utils/hook.tsx
  4. 43 5
      haha-admin-web/src/views/marketing/activity/utils/types.ts
  5. 145 0
      haha-admin-web/src/views/marketing/components/DeviceSelector.vue
  6. 23 0
      haha-admin-web/src/views/timed-discount/utils/hook.tsx
  7. 5 0
      haha-admin-web/src/views/timed-discount/utils/types.ts
  8. 12 0
      haha-admin/src/main/resources/sql/marketing.sql
  9. 12 0
      haha-admin/src/main/resources/sql/timed_discount.sql
  10. 24 0
      haha-entity/src/main/java/com/haha/entity/ActivityDevice.java
  11. 8 0
      haha-entity/src/main/java/com/haha/entity/MarketingActivity.java
  12. 8 0
      haha-entity/src/main/java/com/haha/entity/TimedDiscountActivity.java
  13. 24 0
      haha-entity/src/main/java/com/haha/entity/TimedDiscountDevice.java
  14. 4 0
      haha-entity/src/main/java/com/haha/entity/dto/ActivityCreateDTO.java
  15. 4 0
      haha-entity/src/main/java/com/haha/entity/dto/ActivityUpdateDTO.java
  16. 13 9
      haha-entity/src/main/java/com/haha/entity/dto/TimedDiscountCreateDTO.java
  17. 13 9
      haha-entity/src/main/java/com/haha/entity/dto/TimedDiscountUpdateDTO.java
  18. 23 0
      haha-mapper/src/main/java/com/haha/mapper/ActivityDeviceMapper.java
  19. 23 0
      haha-mapper/src/main/java/com/haha/mapper/TimedDiscountDeviceMapper.java
  20. 2 0
      haha-service/src/main/java/com/haha/service/MarketingActivityService.java
  21. 31 0
      haha-service/src/main/java/com/haha/service/MarketingRuleService.java
  22. 2 0
      haha-service/src/main/java/com/haha/service/TimedDiscountService.java
  23. 25 0
      haha-service/src/main/java/com/haha/service/impl/MarketingActivityServiceImpl.java
  24. 219 0
      haha-service/src/main/java/com/haha/service/impl/MarketingRuleServiceImpl.java
  25. 0 1
      haha-service/src/main/java/com/haha/service/impl/OrderServiceImpl.java
  26. 33 0
      haha-service/src/main/java/com/haha/service/impl/TimedDiscountServiceImpl.java

+ 2 - 2
haha-admin-web/src/api/activity.ts

@@ -20,8 +20,8 @@ type ResultTable = {
 export const getActivityList = (params: {
   page?: number;
   pageSize?: number;
-  name?: string;
-  type?: number;
+  activityName?: string;
+  activityType?: number;
   status?: number;
 }) => {
   return http.request<ResultTable>("get", "/marketing/activity/list", {

+ 7 - 7
haha-admin-web/src/views/marketing/activity/index.vue

@@ -61,10 +61,9 @@ const {
           clearable
           class="w-[180px]!"
         >
-          <el-option label="折扣活动" :value="1" />
-          <el-option label="满减活动" :value="2" />
-          <el-option label="赠品活动" :value="3" />
-          <el-option label="秒杀活动" :value="4" />
+          <el-option label="首单立减" :value="1" />
+          <el-option label="优惠折扣" :value="2" />
+          <el-option label="商品满减" :value="3" />
         </el-select>
       </el-form-item>
       <el-form-item label="状态:" prop="status">
@@ -75,9 +74,10 @@ const {
           class="w-[180px]!"
         >
           <el-option label="草稿" :value="0" />
-          <el-option label="进行中" :value="1" />
-          <el-option label="已暂停" :value="2" />
-          <el-option label="已结束" :value="3" />
+          <el-option label="已发布" :value="1" />
+          <el-option label="进行中" :value="2" />
+          <el-option label="已暂停" :value="3" />
+          <el-option label="已结束" :value="4" />
         </el-select>
       </el-form-item>
       <el-form-item>

+ 357 - 170
haha-admin-web/src/views/marketing/activity/utils/hook.tsx

@@ -11,23 +11,20 @@ import {
   pauseActivity,
   resumeActivity
 } from "@/api/activity";
-import { getActivityDetail, getActivityCoupons } from "@/api/marketing";
-import type { PaginationProps } from "@pureadmin/table";
+import type { PaginationProps, TableColumnList } from "@pureadmin/table";
 import { deviceDetection } from "@pureadmin/utils";
 import { onMounted, reactive, ref, toRaw } from "vue";
 import {
   ElForm,
   ElInput,
   ElFormItem,
-  ElSelect,
-  ElOption,
   ElDatePicker,
+  ElInputNumber,
   ElDescriptions,
   ElDescriptionsItem,
-  ElTabs,
-  ElTabPane,
-  ElTable,
-  ElTableColumn
+  ElTag,
+  ElRadioGroup,
+  ElRadioButton
 } from "element-plus";
 import { 
   initPagination, 
@@ -36,6 +33,10 @@ import {
   resetPagination,
   updatePaginationData 
 } from "@/utils/paginationHelper";
+import ShopSelector from "../../components/ShopSelector.vue";
+import DeviceSelector from "../../components/DeviceSelector.vue";
+import ProductSelector from "../../components/ProductSelector.vue";
+import type { FormItemProps } from "./types";
 
 interface SearchFormProps {
   name: string;
@@ -53,18 +54,33 @@ export function useActivity() {
   const dataList = ref([]);
   const pagination = reactive<PaginationProps>(initPagination());
 
-  const typeMap = {
-    1: "折扣活动",
-    2: "满减活动",
-    3: "赠品活动",
-    4: "秒杀活动"
+  const typeMap: Record<number, { text: string; type: string; color: string }> = {
+    1: { text: "首单立减", type: "primary", color: "#409EFF" },
+    2: { text: "优惠折扣", type: "success", color: "#67C23A" },
+    3: { text: "商品满减", type: "warning", color: "#E6A23C" }
   };
 
-  const statusMap = {
+  const statusMap: Record<number, { text: string; type: string }> = {
     0: { text: "草稿", type: "info" },
-    1: { text: "进行中", type: "success" },
-    2: { text: "已暂停", type: "warning" },
-    3: { text: "已结束", type: "info" }
+    1: { text: "已发布", type: "primary" },
+    2: { text: "进行中", type: "success" },
+    3: { text: "已暂停", type: "warning" },
+    4: { text: "已结束", type: "info" }
+  };
+
+  const applyScopeMap: Record<number, { text: string; type: string }> = {
+    1: { text: "全部门店", type: "primary" },
+    2: { text: "指定门店", type: "warning" }
+  };
+
+  const deviceScopeMap: Record<number, { text: string; type: string }> = {
+    1: { text: "全部设备", type: "primary" },
+    2: { text: "指定设备", type: "warning" }
+  };
+
+  const productScopeMap: Record<number, { text: string; type: string }> = {
+    1: { text: "全部商品", type: "primary" },
+    2: { text: "指定商品", type: "warning" }
   };
 
   const columns: TableColumnList = [
@@ -75,14 +91,32 @@ export function useActivity() {
     },
     {
       label: "活动名称",
-      prop: "name",
+      prop: "activityName",
       minWidth: 150
     },
     {
       label: "活动类型",
-      prop: "type",
+      prop: "activityType",
       minWidth: 100,
-      formatter: ({ type }) => typeMap[type] || "未知"
+      cellRenderer: ({ row }) => {
+        const item = typeMap[row.activityType] || { text: "未知", type: "info" };
+        return <ElTag type={item.type}>{item.text}</ElTag>;
+      }
+    },
+    {
+      label: "优惠规则",
+      prop: "discountValue",
+      minWidth: 120,
+      formatter: ({ activityType, discountValue, minAmount }) => {
+        if (activityType === 1) {
+          return `首单立减 ¥${discountValue || 0}`;
+        } else if (activityType === 2) {
+          return `${((discountValue || 0) * 10).toFixed(1)}折优惠`;
+        } else if (activityType === 3) {
+          return `满${minAmount || 0}减${discountValue || 0}`;
+        }
+        return "-";
+      }
     },
     {
       label: "开始时间",
@@ -104,7 +138,7 @@ export function useActivity() {
       minWidth: 100,
       cellRenderer: ({ row }) => {
         const item = statusMap[row.status] || { text: "未知", type: "info" };
-        return <el-tag type={item.type}>{item.text}</el-tag>;
+        return <ElTag type={item.type}>{item.text}</ElTag>;
       }
     },
     {
@@ -130,9 +164,8 @@ export function useActivity() {
         pageSize: pagination.pageSize
       };
       
-      // 只添加非空的搜索条件
-      if (form.name) searchParams.name = form.name;
-      if (form.type !== undefined) searchParams.type = form.type;
+      if (form.name) searchParams.activityName = form.name;
+      if (form.type !== undefined) searchParams.activityType = form.type;
       if (form.status !== undefined) searchParams.status = form.status;
       
       const { data } = await getActivityList(searchParams);
@@ -192,116 +225,313 @@ export function useActivity() {
       contentRenderer: () => (
         <div>
           <ElDescriptions column={2} border>
-            <ElDescriptionsItem label="活动名称">{row.name}</ElDescriptionsItem>
-            <ElDescriptionsItem label="活动类型">{typeMap[row.type]}</ElDescriptionsItem>
+            <ElDescriptionsItem label="活动名称">{row.activityName}</ElDescriptionsItem>
+            <ElDescriptionsItem label="活动类型">
+              <ElTag type={typeMap[row.activityType]?.type}>{typeMap[row.activityType]?.text}</ElTag>
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="优惠规则">
+              {row.activityType === 1 && `首单立减 ¥${row.discountValue}`}
+              {row.activityType === 2 && `${(row.discountValue * 10).toFixed(1)}折优惠`}
+              {row.activityType === 3 && `满${row.minAmount}减${row.discountValue}`}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="最大优惠">
+              {row.maxDiscount ? `¥${row.maxDiscount}` : "不限制"}
+            </ElDescriptionsItem>
             <ElDescriptionsItem label="开始时间">{dayjs(row.startTime).format("YYYY-MM-DD HH:mm:ss")}</ElDescriptionsItem>
             <ElDescriptionsItem label="结束时间">{dayjs(row.endTime).format("YYYY-MM-DD HH:mm:ss")}</ElDescriptionsItem>
             <ElDescriptionsItem label="状态">
-              <el-tag type={statusMap[row.status]?.type}>{statusMap[row.status]?.text}</el-tag>
+              <ElTag type={statusMap[row.status]?.type}>{statusMap[row.status]?.text}</ElTag>
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="活动预算">
+              {row.totalBudget ? `¥${row.totalBudget}` : "不限制"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="适用范围">
+              <ElTag type={applyScopeMap[row.applyScope]?.type}>{applyScopeMap[row.applyScope]?.text}</ElTag>
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="设备范围">
+              <ElTag type={deviceScopeMap[row.deviceScope]?.type}>{deviceScopeMap[row.deviceScope]?.text}</ElTag>
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="商品范围">
+              <ElTag type={productScopeMap[row.productScope]?.type}>{productScopeMap[row.productScope]?.text}</ElTag>
             </ElDescriptionsItem>
             <ElDescriptionsItem label="创建时间">{dayjs(row.createTime).format("YYYY-MM-DD HH:mm:ss")}</ElDescriptionsItem>
-            <ElDescriptionsItem label="活动说明" span={2}>{row.description || "暂无说明"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="活动说明" span={2}>{row.activityDesc || "暂无说明"}</ElDescriptionsItem>
           </ElDescriptions>
           
           <div class="mt-4">
-            <h4>统计数据</h4>
+            <h4 class="mb-2">统计数据</h4>
             <ElDescriptions column={3}>
-              <ElDescriptionsItem label="参与人数">1,234人</ElDescriptionsItem>
-              <ElDescriptionsItem label="订单数">567单</ElDescriptionsItem>
-              <ElDescriptionsItem label="总营收">¥89,456</ElDescriptionsItem>
+              <ElDescriptionsItem label="参与人数">{row.participantCount || 0}人</ElDescriptionsItem>
+              <ElDescriptionsItem label="订单数">{row.orderCount || 0}单</ElDescriptionsItem>
+              <ElDescriptionsItem label="总营收">¥{row.totalRevenue || 0}</ElDescriptionsItem>
             </ElDescriptions>
           </div>
+        </div>
+      )
+    });
+  }
+
+  const formStyles = `
+    .activity-form { padding: 0; }
+    .form-section { margin-bottom: 16px; }
+    .section-title { 
+      font-size: 14px; 
+      font-weight: 600; 
+      color: #303133; 
+      margin-bottom: 12px; 
+      padding-bottom: 8px; 
+      border-bottom: 1px solid #ebeef5; 
+    }
+    .type-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
+    .type-card { 
+      padding: 16px; 
+      border: 2px solid #e4e7ed; 
+      border-radius: 8px; 
+      cursor: pointer; 
+      transition: all 0.2s; 
+      background: #fff; 
+      text-align: center;
+    }
+    .type-card:hover { border-color: var(--card-color); background: var(--card-bg); }
+    .type-card.active { border-color: var(--card-color); background: var(--card-bg); }
+    .type-card .title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 4px; }
+    .type-card .desc { font-size: 12px; color: #909399; }
+    .scope-row { display: flex; gap: 24px; flex-wrap: wrap; }
+    .scope-item { display: flex; align-items: center; gap: 8px; }
+    .scope-item .label { font-size: 13px; color: #606266; min-width: 60px; }
+    .discount-preview { 
+      display: flex; 
+      align-items: center; 
+      justify-content: center; 
+      padding: 12px; 
+      background: #f5f7fa; 
+      border-radius: 6px; 
+      font-size: 15px; 
+      font-weight: 600; 
+      margin-top: 12px; 
+    }
+    .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+    @media (max-width: 768px) {
+      .type-cards { grid-template-columns: 1fr; }
+      .form-row { grid-template-columns: 1fr; }
+      .scope-row { flex-direction: column; }
+    }
+  `;
+
+  function renderForm(formData: FormItemProps) {
+    return (
+      <div class="activity-form">
+        <style>{formStyles}</style>
+        
+        <div class="form-section">
+          <div class="section-title">选择活动类型</div>
+          <div class="type-cards">
+            {[1, 2, 3].map(type => {
+              const info = typeMap[type];
+              const bgColors = { 1: "#f0f9ff", 2: "#f0f9eb", 3: "#fdf6ec" };
+              return (
+                <div 
+                  class={`type-card ${formData.activityType === type ? 'active' : ''}`}
+                  style={`--card-color: ${info.color}; --card-bg: ${bgColors[type]};`}
+                  onClick={() => { formData.activityType = type; }}
+                >
+                  <div class="title">{info.text}</div>
+                  <div class="desc">
+                    {type === 1 && "新用户首单优惠"}
+                    {type === 2 && "全场折扣优惠"}
+                    {type === 3 && "满额减免优惠"}
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        </div>
+
+        <div class="form-section">
+          <div class="section-title">基本信息</div>
+          <ElForm label-width="80px" size="small">
+            <div class="form-row">
+              <ElFormItem label="活动名称" required>
+                <ElInput v-model={formData.activityName} placeholder="请输入活动名称" clearable maxlength={50} showWordLimit />
+              </ElFormItem>
+              <ElFormItem label="活动预算">
+                <ElInputNumber v-model={formData.totalBudget} min={0} precision={2} class="w-full" placeholder="可选" />
+              </ElFormItem>
+            </div>
+            <ElFormItem label="活动时间" required>
+              <div class="flex gap-2 w-full">
+                <ElDatePicker v-model={formData.startTime} type="datetime" placeholder="开始时间" class="flex-1" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" />
+                <span class="leading-8 text-gray-400">至</span>
+                <ElDatePicker v-model={formData.endTime} type="datetime" placeholder="结束时间" class="flex-1" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" />
+              </div>
+            </ElFormItem>
+          </ElForm>
+        </div>
+
+        <div class="form-section">
+          <div class="section-title">优惠规则</div>
+          {!formData.activityType && <div style="text-align: center; color: #909399; padding: 20px;">请先选择活动类型</div>}
           
-          <div class="mt-4">
-            <h4>关联优惠券</h4>
-            <div class="mb-2">
-              <el-button type="primary" size="small">新建优惠券</el-button>
-              <el-button type="success" size="small" class="ml-2">关联现有优惠券</el-button>
+          {formData.activityType === 1 && (
+            <div>
+              <ElForm label-width="80px" size="small">
+                <ElFormItem label="立减金额" required>
+                  <ElInputNumber v-model={formData.discountValue} min={0.01} precision={2} class="w-full" placeholder="首单立减金额" prefix="¥" />
+                </ElFormItem>
+              </ElForm>
+              <div class="discount-preview" style="color: #409EFF;">新用户首单立减 ¥{formData.discountValue || '0.00'}</div>
+            </div>
+          )}
+
+          {formData.activityType === 2 && (
+            <div>
+              <ElForm label-width="80px" size="small">
+                <div class="form-row">
+                  <ElFormItem label="折扣比例" required>
+                    <ElInputNumber v-model={formData.discountValue} min={0.1} max={1} step={0.1} precision={1} class="w-full" placeholder="如 0.8 表示 8 折" />
+                  </ElFormItem>
+                  <ElFormItem label="最大优惠">
+                    <ElInputNumber v-model={formData.maxDiscount} min={0} precision={2} class="w-full" placeholder="可选" prefix="¥" />
+                  </ElFormItem>
+                </div>
+              </ElForm>
+              <div class="discount-preview" style="color: #67C23A;">
+                全场 {formData.discountValue ? (formData.discountValue * 10).toFixed(1) : '0'} 折优惠
+                {formData.maxDiscount && ` (最高减¥${formData.maxDiscount})`}
+              </div>
+            </div>
+          )}
+
+          {formData.activityType === 3 && (
+            <div>
+              <ElForm label-width="80px" size="small">
+                <div class="form-row">
+                  <ElFormItem label="满减门槛" required>
+                    <ElInputNumber v-model={formData.minAmount} min={0} precision={2} class="w-full" placeholder="订单满多少" prefix="¥" />
+                  </ElFormItem>
+                  <ElFormItem label="减免金额" required>
+                    <ElInputNumber v-model={formData.discountValue} min={0.01} precision={2} class="w-full" placeholder="减免金额" prefix="¥" />
+                  </ElFormItem>
+                </div>
+              </ElForm>
+              <div class="discount-preview" style="color: #E6A23C;">满 ¥{formData.minAmount || '0'} 减 ¥{formData.discountValue || '0'}</div>
+            </div>
+          )}
+        </div>
+
+        <div class="form-section">
+          <div class="section-title">适用范围</div>
+          <div class="scope-row">
+            <div class="scope-item">
+              <span class="label">门店范围</span>
+              <ElRadioGroup v-model={formData.applyScope} size="small">
+                <ElRadioButton value={1}>全部</ElRadioButton>
+                <ElRadioButton value={2}>指定</ElRadioButton>
+              </ElRadioGroup>
+              {formData.applyScope === 2 && <ShopSelector v-model={formData.shopIds} />}
+            </div>
+            <div class="scope-item">
+              <span class="label">设备范围</span>
+              <ElRadioGroup v-model={formData.deviceScope} size="small">
+                <ElRadioButton value={1}>全部</ElRadioButton>
+                <ElRadioButton value={2}>指定</ElRadioButton>
+              </ElRadioGroup>
+              {formData.deviceScope === 2 && <DeviceSelector v-model={formData.deviceIds} />}
+            </div>
+            <div class="scope-item">
+              <span class="label">商品范围</span>
+              <ElRadioGroup v-model={formData.productScope} size="small">
+                <ElRadioButton value={1}>全部</ElRadioButton>
+                <ElRadioButton value={2}>指定</ElRadioButton>
+              </ElRadioGroup>
+              {formData.productScope === 2 && <ProductSelector v-model={formData.productIds} />}
             </div>
-            <ElTable data={[]} style="width: 100%" empty-text="暂无关联优惠券">
-              <ElTableColumn prop="name" label="优惠券名称" width="150" />
-              <ElTableColumn prop="type" label="类型" width="100" />
-              <ElTableColumn prop="status" label="状态" width="100" />
-              <ElTableColumn label="操作" width="150" v-slots={{
-                default: ({ row }) => (
-                  <>
-                    <el-button link type="primary" size="small">查看详情</el-button>
-                    <el-button link type="danger" size="small" class="ml-2">解除关联</el-button>
-                  </>
-                )
-              }} />
-            </ElTable>
           </div>
         </div>
-      )
-    });
+
+        <div class="form-section">
+          <div class="section-title">活动说明</div>
+          <ElForm size="small">
+            <ElFormItem label="">
+              <ElInput v-model={formData.activityDesc} type="textarea" rows={2} placeholder="请输入活动说明(可选)" maxlength={500} showWordLimit />
+            </ElFormItem>
+          </ElForm>
+        </div>
+      </div>
+    );
+  }
+
+  function validateForm(formData: FormItemProps): boolean {
+    if (!formData.activityName) {
+      message("请输入活动名称", { type: "warning" });
+      return false;
+    }
+    if (!formData.activityType) {
+      message("请选择活动类型", { type: "warning" });
+      return false;
+    }
+    if (!formData.startTime) {
+      message("请选择开始时间", { type: "warning" });
+      return false;
+    }
+    if (!formData.endTime) {
+      message("请选择结束时间", { type: "warning" });
+      return false;
+    }
+    if (!formData.discountValue || formData.discountValue <= 0) {
+      message("请输入有效的优惠值", { type: "warning" });
+      return false;
+    }
+    if (formData.activityType === 3 && (!formData.minAmount || formData.minAmount <= 0)) {
+      message("请输入有效的满减门槛", { type: "warning" });
+      return false;
+    }
+    if (formData.applyScope === 2 && (!formData.shopIds || formData.shopIds.length === 0)) {
+      message("请选择至少一个门店", { type: "warning" });
+      return false;
+    }
+    if (formData.deviceScope === 2 && (!formData.deviceIds || formData.deviceIds.length === 0)) {
+      message("请选择至少一个设备", { type: "warning" });
+      return false;
+    }
+    if (formData.productScope === 2 && (!formData.productIds || formData.productIds.length === 0)) {
+      message("请选择至少一个商品", { type: "warning" });
+      return false;
+    }
+    return true;
   }
 
   function handleEdit(row) {
-    const formData = reactive({
+    const formData = reactive<FormItemProps>({
       id: row.id,
-      name: row.name,
-      type: row.type,
+      activityName: row.activityName,
+      activityType: row.activityType,
+      activityDesc: row.activityDesc || "",
       startTime: row.startTime,
       endTime: row.endTime,
-      description: row.description
+      status: row.status,
+      discountValue: row.discountValue,
+      minAmount: row.minAmount,
+      maxDiscount: row.maxDiscount,
+      applyScope: row.applyScope || 1,
+      deviceScope: row.deviceScope || 1,
+      productScope: row.productScope || 1,
+      totalBudget: row.totalBudget,
+      shopIds: row.shopIds || [],
+      deviceIds: row.deviceIds || [],
+      productIds: row.productIds || []
     });
 
     addDialog({
       title: "编辑活动",
-      width: "600px",
+      width: "800px",
       draggable: true,
       fullscreen: deviceDetection(),
-      contentRenderer: () => (
-        <ElForm label-width="100px">
-          <ElFormItem label="活动名称" required>
-            <ElInput v-model={formData.name} placeholder="请输入活动名称" clearable />
-          </ElFormItem>
-          <ElFormItem label="活动类型" required>
-            <ElSelect v-model={formData.type} placeholder="请选择活动类型" class="w-full" clearable>
-              <ElOption label="折扣活动" value={1} />
-              <ElOption label="满减活动" value={2} />
-              <ElOption label="赠品活动" value={3} />
-              <ElOption label="秒杀活动" value={4} />
-            </ElSelect>
-          </ElFormItem>
-          <ElFormItem label="开始时间" required>
-            <ElDatePicker
-              v-model={formData.startTime}
-              type="datetime"
-              placeholder="请选择开始时间"
-              class="w-full"
-              format="YYYY-MM-DD HH:mm:ss"
-              value-format="YYYY-MM-DD HH:mm:ss"
-            />
-          </ElFormItem>
-          <ElFormItem label="结束时间" required>
-            <ElDatePicker
-              v-model={formData.endTime}
-              type="datetime"
-              placeholder="请选择结束时间"
-              class="w-full"
-              format="YYYY-MM-DD HH:mm:ss"
-              value-format="YYYY-MM-DD HH:mm:ss"
-            />
-          </ElFormItem>
-          <ElFormItem label="活动说明">
-            <ElInput
-              v-model={formData.description}
-              type="textarea"
-              rows={4}
-              placeholder="请输入活动说明"
-            />
-          </ElFormItem>
-        </ElForm>
-      ),
+      contentRenderer: () => renderForm(formData),
       beforeSure: async (done) => {
-        if (!formData.name) {
-          message("请输入活动名称", { type: "warning" });
-          return;
-        }
+        if (!validateForm(formData)) return;
         try {
-          const { code } = await updateActivity(formData.id, toRaw(formData));
+          const { code } = await updateActivity(formData.id!, toRaw(formData));
           if (code === 200) {
             message("编辑成功", { type: "success" });
             done();
@@ -315,79 +545,33 @@ export function useActivity() {
   }
 
   function handleAdd() {
-    const formData = reactive({
-      name: "",
-      type: undefined,
+    const formData = reactive<FormItemProps>({
+      activityName: "",
+      activityType: undefined,
+      activityDesc: "",
       startTime: "",
       endTime: "",
-      description: ""
+      status: 0,
+      discountValue: null,
+      minAmount: null,
+      maxDiscount: null,
+      applyScope: 1,
+      deviceScope: 1,
+      productScope: 1,
+      totalBudget: null,
+      shopIds: [],
+      deviceIds: [],
+      productIds: []
     });
 
     addDialog({
       title: "新增活动",
-      width: "600px",
+      width: "800px",
       draggable: true,
       fullscreen: deviceDetection(),
-      contentRenderer: () => (
-        <ElForm label-width="100px">
-          <ElFormItem label="活动名称" required>
-            <ElInput v-model={formData.name} placeholder="请输入活动名称" clearable />
-          </ElFormItem>
-          <ElFormItem label="活动类型" required>
-            <ElSelect v-model={formData.type} placeholder="请选择活动类型" class="w-full" clearable>
-              <ElOption label="折扣活动" value={1} />
-              <ElOption label="满减活动" value={2} />
-              <ElOption label="赠品活动" value={3} />
-              <ElOption label="秒杀活动" value={4} />
-            </ElSelect>
-          </ElFormItem>
-          <ElFormItem label="开始时间" required>
-            <ElDatePicker
-              v-model={formData.startTime}
-              type="datetime"
-              placeholder="请选择开始时间"
-              class="w-full"
-              format="YYYY-MM-DD HH:mm:ss"
-              value-format="YYYY-MM-DD HH:mm:ss"
-            />
-          </ElFormItem>
-          <ElFormItem label="结束时间" required>
-            <ElDatePicker
-              v-model={formData.endTime}
-              type="datetime"
-              placeholder="请选择结束时间"
-              class="w-full"
-              format="YYYY-MM-DD HH:mm:ss"
-              value-format="YYYY-MM-DD HH:mm:ss"
-            />
-          </ElFormItem>
-          <ElFormItem label="活动说明">
-            <ElInput
-              v-model={formData.description}
-              type="textarea"
-              rows={4}
-              placeholder="请输入活动说明"
-            />
-          </ElFormItem>
-        </ElForm>
-      ),
+      contentRenderer: () => renderForm(formData),
       beforeSure: async (done) => {
-        if (!formData.name) {
-          message("请输入活动名称", { type: "warning" });
-          return;
-        }
-        if (!formData.type) {
-          message("请选择活动类型", { type: "warning" });
-          return;
-        }
-        if (!formData.startTime) {
-          message("请选择开始时间", { type: "warning" });
-          return;
-        }
-        if (!formData.endTime) {
-          message("请选择结束时间", { type: "warning" });
-          return;
-        }
+        if (!validateForm(formData)) return;
         try {
           const { code } = await createActivity(toRaw(formData));
           if (code === 200) {
@@ -422,6 +606,9 @@ export function useActivity() {
     pagination,
     typeMap,
     statusMap,
+    applyScopeMap,
+    deviceScopeMap,
+    productScopeMap,
     onSearch,
     resetForm,
     handleAdd,
@@ -434,4 +621,4 @@ export function useActivity() {
     handleSizeChange,
     handleCurrentChange
   };
-}
+}

+ 43 - 5
haha-admin-web/src/views/marketing/activity/utils/types.ts

@@ -1,14 +1,21 @@
 interface FormItemProps {
   id?: number;
-  name: string;
-  type: number;
-  description?: string;
+  activityName: string;
+  activityType: number;
+  activityDesc?: string;
   startTime: string;
   endTime: string;
   status: number;
+  discountValue: number | null;
+  minAmount: number | null;
+  maxDiscount: number | null;
+  applyScope: number;
+  deviceScope: number;
+  productScope: number;
+  totalBudget: number | null;
   shopIds?: number[];
+  deviceIds?: string[];
   productIds?: number[];
-  rules?: any;
 }
 
 interface SearchFormProps {
@@ -19,4 +26,35 @@ interface SearchFormProps {
   pageSize?: number;
 }
 
-export type { FormItemProps, SearchFormProps };
+interface ActivityDetail {
+  id: number;
+  activityName: string;
+  activityType: number;
+  activityDesc: string;
+  startTime: string;
+  endTime: string;
+  status: number;
+  discountValue: number;
+  minAmount: number;
+  maxDiscount: number;
+  applyScope: number;
+  deviceScope: number;
+  productScope: number;
+  totalBudget: number;
+  usedBudget: number;
+  creatorId: number;
+  creatorName: string;
+  createTime: string;
+  updateTime: string;
+  statusLabel: string;
+  statusColor: string;
+  typeLabel: string;
+  applyScopeLabel: string;
+  deviceScopeLabel: string;
+  productScopeLabel: string;
+  shopIds: number[];
+  deviceIds: string[];
+  productIds: number[];
+}
+
+export type { FormItemProps, SearchFormProps, ActivityDetail };

+ 145 - 0
haha-admin-web/src/views/marketing/components/DeviceSelector.vue

@@ -0,0 +1,145 @@
+<script setup lang="ts">
+import { ref, watch, onMounted } from "vue";
+import { getDeviceList } from "@/api/device";
+
+defineOptions({
+  name: "DeviceSelector"
+});
+
+const props = defineProps<{
+  modelValue: string[];
+}>();
+
+const emit = defineEmits<{
+  (e: "update:modelValue", value: string[]): void;
+}>();
+
+const selectedDevices = ref<string[]>([...props.modelValue]);
+const deviceList = ref<any[]>([]);
+const loading = ref(false);
+const dialogVisible = ref(false);
+
+const selectedDeviceNames = ref<string[]>([]);
+
+async function loadDevices() {
+  loading.value = true;
+  try {
+    const { data } = await getDeviceList({ page: 1, pageSize: 1000 });
+    if (data) {
+      deviceList.value = data.list || [];
+      updateSelectedNames();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+function updateSelectedNames() {
+  selectedDeviceNames.value = selectedDevices.value.map(id => {
+    const device = deviceList.value.find(d => d.deviceId === id);
+    return device ? device.name : id;
+  });
+}
+
+function handleSelectionChange(selection: any[]) {
+  selectedDevices.value = selection.map(item => item.deviceId);
+}
+
+function confirmSelection() {
+  emit("update:modelValue", [...selectedDevices.value]);
+  updateSelectedNames();
+  dialogVisible.value = false;
+}
+
+function removeDevice(deviceId: string) {
+  const index = selectedDevices.value.indexOf(deviceId);
+  if (index > -1) {
+    selectedDevices.value.splice(index, 1);
+    emit("update:modelValue", [...selectedDevices.value]);
+    updateSelectedNames();
+  }
+}
+
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    selectedDevices.value = [...newVal];
+    updateSelectedNames();
+  }
+);
+
+onMounted(() => {
+  loadDevices();
+});
+</script>
+
+<template>
+  <div class="device-selector">
+    <div class="selected-tags flex flex-wrap gap-2 mb-2">
+      <el-tag
+        v-for="(name, index) in selectedDeviceNames"
+        :key="index"
+        closable
+        @close="removeDevice(selectedDevices[index])"
+      >
+        {{ name }}
+      </el-tag>
+      <el-button
+        v-if="selectedDevices.length === 0"
+        type="primary"
+        link
+        @click="dialogVisible = true"
+      >
+        选择设备
+      </el-button>
+    </div>
+    <el-button
+      v-if="selectedDevices.length > 0"
+      type="primary"
+      link
+      @click="dialogVisible = true"
+    >
+      修改选择
+    </el-button>
+
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择设备"
+      width="600px"
+      draggable
+      destroy-on-close
+    >
+      <el-table
+        :data="deviceList"
+        v-loading="loading"
+        max-height="400"
+        @selection-change="handleSelectionChange"
+        :row-key="row => row.deviceId"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column prop="deviceId" label="设备ID" width="120" />
+        <el-table-column prop="name" label="设备名称" min-width="150" />
+        <el-table-column prop="shopName" label="所属门店" min-width="120" />
+        <el-table-column prop="status" label="状态" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'">
+              {{ row.status === 1 ? '在线' : '离线' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmSelection">
+          确定选择 ({{ selectedDevices.length }}个)
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.device-selector {
+  width: 100%;
+}
+</style>

+ 23 - 0
haha-admin-web/src/views/timed-discount/utils/hook.tsx

@@ -45,6 +45,9 @@ import {
   updatePaginationData 
 } from "@/utils/paginationHelper";
 import type { SearchFormProps, RecordSearchFormProps, FormDataProps, TimedDiscountActivity, TimedDiscountRecord, PriceAdjustmentLog } from "./types";
+import ShopSelector from "@/views/marketing/components/ShopSelector.vue";
+import DeviceSelector from "@/views/marketing/components/DeviceSelector.vue";
+import ProductSelector from "@/views/marketing/components/ProductSelector.vue";
 
 const discountTypeMap = {
   1: { text: "固定折扣", type: "primary" },
@@ -274,6 +277,7 @@ export function useTimedDiscount() {
       discountValue: row.discountValue,
       minDiscountPrice: row.minDiscountPrice || null,
       applyScope: row.applyScope,
+      deviceScope: row.deviceScope || 1,
       productScope: row.productScope,
       productTypes: row.productTypes || "",
       priorityProductTypes: row.priorityProductTypes || "",
@@ -282,6 +286,7 @@ export function useTimedDiscount() {
       autoExecute: row.autoExecute,
       notifyOnExecute: row.notifyOnExecute || 0,
       shopIds: row.shopIds || [],
+      deviceIds: row.deviceIds || [],
       productIds: row.productIds || []
     });
 
@@ -317,6 +322,7 @@ export function useTimedDiscount() {
       discountValue: 0.5,
       minDiscountPrice: 1,
       applyScope: 1,
+      deviceScope: 1,
       productScope: 2,
       productTypes: "自制餐品,盒饭",
       priorityProductTypes: "自制餐品,盒饭",
@@ -325,6 +331,7 @@ export function useTimedDiscount() {
       autoExecute: 1,
       notifyOnExecute: 0,
       shopIds: [],
+      deviceIds: [],
       productIds: []
     });
 
@@ -411,6 +418,22 @@ export function useTimedDiscount() {
             <ElOption label="指定门店" value={2} />
           </ElSelect>
         </ElFormItem>
+        {formData.applyScope === 2 && (
+          <ElFormItem label="选择门店">
+            <ShopSelector v-model={formData.shopIds} />
+          </ElFormItem>
+        )}
+        <ElFormItem label="设备范围">
+          <ElSelect v-model={formData.deviceScope} placeholder="请选择设备范围" class="w-full">
+            <ElOption label="全部设备" value={1} />
+            <ElOption label="指定设备" value={2} />
+          </ElSelect>
+        </ElFormItem>
+        {formData.deviceScope === 2 && (
+          <ElFormItem label="选择设备">
+            <DeviceSelector v-model={formData.deviceIds} />
+          </ElFormItem>
+        )}
         <ElFormItem label="商品范围">
           <ElSelect v-model={formData.productScope} placeholder="请选择商品范围" class="w-full">
             <ElOption label="全部商品" value={1} />

+ 5 - 0
haha-admin-web/src/views/timed-discount/utils/types.ts

@@ -8,6 +8,7 @@ export interface TimedDiscountActivity {
   discountValue: number;
   minDiscountPrice: number;
   applyScope: number;
+  deviceScope: number;
   productScope: number;
   productTypes: string;
   priorityProductTypes: string;
@@ -24,8 +25,10 @@ export interface TimedDiscountActivity {
   statusColor: string;
   discountTypeLabel: string;
   applyScopeLabel: string;
+  deviceScopeLabel: string;
   productScopeLabel: string;
   shopIds: number[];
+  deviceIds: string[];
   productIds: number[];
   productTypeList: string[];
   priorityProductTypeList: string[];
@@ -128,6 +131,7 @@ export interface FormDataProps {
   discountValue: number;
   minDiscountPrice: number;
   applyScope: number;
+  deviceScope: number;
   productScope: number;
   productTypes: string;
   priorityProductTypes: string;
@@ -136,5 +140,6 @@ export interface FormDataProps {
   autoExecute: number;
   notifyOnExecute: number;
   shopIds: number[];
+  deviceIds: string[];
   productIds: number[];
 }

+ 12 - 0
haha-admin/src/main/resources/sql/marketing.sql

@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `t_marketing_activity` (
   `min_amount` decimal(10,2) DEFAULT NULL COMMENT '最低消费金额(满减门槛)',
   `max_discount` decimal(10,2) DEFAULT NULL COMMENT '最大优惠金额',
   `apply_scope` tinyint NOT NULL DEFAULT 1 COMMENT '适用范围:1-全部门店 2-指定门店',
+  `device_scope` tinyint NOT NULL DEFAULT 1 COMMENT '设备范围:1-全部设备 2-指定设备',
   `product_scope` tinyint NOT NULL DEFAULT 1 COMMENT '商品范围:1-全部商品 2-指定商品',
   `total_budget` decimal(10,2) DEFAULT NULL COMMENT '活动总预算',
   `used_budget` decimal(10,2) DEFAULT 0 COMMENT '已使用预算',
@@ -50,6 +51,17 @@ CREATE TABLE IF NOT EXISTS `t_activity_product` (
   KEY `idx_product` (`product_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动商品关联表';
 
+-- 3.1 活动设备关联表
+CREATE TABLE IF NOT EXISTS `t_activity_device` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `activity_id` bigint NOT NULL COMMENT '活动ID',
+  `device_id` varchar(50) NOT NULL COMMENT '设备ID',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_device` (`activity_id`, `device_id`),
+  KEY `idx_device` (`device_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动设备关联表';
+
 -- 4. 优惠券模板表
 CREATE TABLE IF NOT EXISTS `t_coupon_template` (
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',

+ 12 - 0
haha-admin/src/main/resources/sql/timed_discount.sql

@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `t_timed_discount_activity` (
   `discount_value` decimal(10,2) NOT NULL COMMENT '折扣值(折扣比例如0.7表示7折,或固定价格)',
   `min_discount_price` decimal(10,2) DEFAULT NULL COMMENT '最低折扣价(防止价格过低)',
   `apply_scope` tinyint NOT NULL DEFAULT 1 COMMENT '适用范围:1-全部门店 2-指定门店',
+  `device_scope` tinyint NOT NULL DEFAULT 1 COMMENT '设备范围:1-全部设备 2-指定设备',
   `product_scope` tinyint NOT NULL DEFAULT 1 COMMENT '商品范围:1-全部商品 2-指定分类 3-指定商品',
   `product_types` varchar(500) DEFAULT NULL COMMENT '适用商品分类(逗号分隔,如:自制餐品,盒饭)',
   `priority_product_types` varchar(500) DEFAULT NULL COMMENT '优先商品分类(自制餐品等,逗号分隔)',
@@ -42,6 +43,17 @@ CREATE TABLE IF NOT EXISTS `t_timed_discount_shop` (
   KEY `idx_shop` (`shop_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='定时折扣活动门店关联表';
 
+-- 2.1 定时折扣活动设备关联表
+CREATE TABLE IF NOT EXISTS `t_timed_discount_device` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `activity_id` bigint NOT NULL COMMENT '活动ID',
+  `device_id` varchar(50) NOT NULL COMMENT '设备ID',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_device` (`activity_id`, `device_id`),
+  KEY `idx_device` (`device_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='定时折扣活动设备关联表';
+
 -- 3. 定时折扣活动商品关联表
 CREATE TABLE IF NOT EXISTS `t_timed_discount_product` (
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',

+ 24 - 0
haha-entity/src/main/java/com/haha/entity/ActivityDevice.java

@@ -0,0 +1,24 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_activity_device")
+public class ActivityDevice implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+
+    private String deviceId;
+
+    private LocalDateTime createTime;
+}

+ 8 - 0
haha-entity/src/main/java/com/haha/entity/MarketingActivity.java

@@ -39,6 +39,8 @@ public class MarketingActivity implements Serializable {
 
     private Integer applyScope;
 
+    private Integer deviceScope;
+
     private Integer productScope;
 
     private BigDecimal totalBudget;
@@ -67,12 +69,18 @@ public class MarketingActivity implements Serializable {
     @TableField(exist = false)
     private String applyScopeLabel;
 
+    @TableField(exist = false)
+    private String deviceScopeLabel;
+
     @TableField(exist = false)
     private String productScopeLabel;
 
     @TableField(exist = false)
     private List<Long> shopIds;
 
+    @TableField(exist = false)
+    private List<String> deviceIds;
+
     @TableField(exist = false)
     private List<Long> productIds;
 }

+ 8 - 0
haha-entity/src/main/java/com/haha/entity/TimedDiscountActivity.java

@@ -36,6 +36,8 @@ public class TimedDiscountActivity implements Serializable {
 
     private Integer applyScope;
 
+    private Integer deviceScope;
+
     private Integer productScope;
 
     private String productTypes;
@@ -74,12 +76,18 @@ public class TimedDiscountActivity implements Serializable {
     @TableField(exist = false)
     private String applyScopeLabel;
 
+    @TableField(exist = false)
+    private String deviceScopeLabel;
+
     @TableField(exist = false)
     private String productScopeLabel;
 
     @TableField(exist = false)
     private List<Long> shopIds;
 
+    @TableField(exist = false)
+    private List<String> deviceIds;
+
     @TableField(exist = false)
     private List<Long> productIds;
 

+ 24 - 0
haha-entity/src/main/java/com/haha/entity/TimedDiscountDevice.java

@@ -0,0 +1,24 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_timed_discount_device")
+public class TimedDiscountDevice implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+
+    private String deviceId;
+
+    private LocalDateTime createTime;
+}

+ 4 - 0
haha-entity/src/main/java/com/haha/entity/dto/ActivityCreateDTO.java

@@ -29,11 +29,15 @@ public class ActivityCreateDTO extends PageQueryDTO {
 
     private Integer applyScope;
 
+    private Integer deviceScope;
+
     private Integer productScope;
 
     private BigDecimal totalBudget;
 
     private List<Long> shopIds;
 
+    private List<String> deviceIds;
+
     private List<Long> productIds;
 }

+ 4 - 0
haha-entity/src/main/java/com/haha/entity/dto/ActivityUpdateDTO.java

@@ -27,11 +27,15 @@ public class ActivityUpdateDTO {
 
     private Integer applyScope;
 
+    private Integer deviceScope;
+
     private Integer productScope;
 
     private BigDecimal totalBudget;
 
     private List<Long> shopIds;
 
+    private List<String> deviceIds;
+
     private List<Long> productIds;
 }

+ 13 - 9
haha-entity/src/main/java/com/haha/entity/dto/TimedDiscountCreateDTO.java

@@ -24,22 +24,26 @@ public class TimedDiscountCreateDTO {
     private BigDecimal minDiscountPrice;
     
     private Integer applyScope;
-    
+
+    private Integer deviceScope;
+
     private Integer productScope;
-    
+
     private String productTypes;
-    
+
     private String priorityProductTypes;
-    
+
     private Integer minStock;
-    
+
     private Integer maxStock;
-    
+
     private Integer autoExecute;
-    
+
     private Integer notifyOnExecute;
-    
+
     private List<Long> shopIds;
-    
+
+    private List<String> deviceIds;
+
     private List<Long> productIds;
 }

+ 13 - 9
haha-entity/src/main/java/com/haha/entity/dto/TimedDiscountUpdateDTO.java

@@ -26,22 +26,26 @@ public class TimedDiscountUpdateDTO {
     private BigDecimal minDiscountPrice;
     
     private Integer applyScope;
-    
+
+    private Integer deviceScope;
+
     private Integer productScope;
-    
+
     private String productTypes;
-    
+
     private String priorityProductTypes;
-    
+
     private Integer minStock;
-    
+
     private Integer maxStock;
-    
+
     private Integer autoExecute;
-    
+
     private Integer notifyOnExecute;
-    
+
     private List<Long> shopIds;
-    
+
+    private List<String> deviceIds;
+
     private List<Long> productIds;
 }

+ 23 - 0
haha-mapper/src/main/java/com/haha/mapper/ActivityDeviceMapper.java

@@ -0,0 +1,23 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.ActivityDevice;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ActivityDeviceMapper extends BaseMapper<ActivityDevice> {
+
+    @Select("SELECT device_id FROM t_activity_device WHERE activity_id = #{activityId}")
+    List<String> selectDeviceIdsByActivityId(@Param("activityId") Long activityId);
+
+    @Delete("DELETE FROM t_activity_device WHERE activity_id = #{activityId}")
+    int deleteByActivityId(@Param("activityId") Long activityId);
+
+    @Select("SELECT activity_id FROM t_activity_device WHERE device_id = #{deviceId}")
+    List<Long> selectActivityIdsByDeviceId(@Param("deviceId") String deviceId);
+}

+ 23 - 0
haha-mapper/src/main/java/com/haha/mapper/TimedDiscountDeviceMapper.java

@@ -0,0 +1,23 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.TimedDiscountDevice;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface TimedDiscountDeviceMapper extends BaseMapper<TimedDiscountDevice> {
+
+    @Select("SELECT device_id FROM t_timed_discount_device WHERE activity_id = #{activityId}")
+    List<String> selectDeviceIdsByActivityId(@Param("activityId") Long activityId);
+
+    @Delete("DELETE FROM t_timed_discount_device WHERE activity_id = #{activityId}")
+    int deleteByActivityId(@Param("activityId") Long activityId);
+
+    @Select("SELECT activity_id FROM t_timed_discount_device WHERE device_id = #{deviceId}")
+    List<Long> selectActivityIdsByDeviceId(@Param("deviceId") String deviceId);
+}

+ 2 - 0
haha-service/src/main/java/com/haha/service/MarketingActivityService.java

@@ -32,6 +32,8 @@ public interface MarketingActivityService extends IService<MarketingActivity> {
 
     List<Long> getActivityShopIds(Long activityId);
 
+    List<String> getActivityDeviceIds(Long activityId);
+
     List<Long> getActivityProductIds(Long activityId);
 
     Map<String, Object> getStatistics(Long activityId);

+ 31 - 0
haha-service/src/main/java/com/haha/service/MarketingRuleService.java

@@ -0,0 +1,31 @@
+package com.haha.service;
+
+import com.haha.entity.MarketingActivity;
+import com.haha.entity.Order;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+public interface MarketingRuleService {
+
+    BigDecimal calculateDiscount(Long orderId, Long activityId);
+
+    BigDecimal calculateFirstOrderDiscount(Order order, MarketingActivity activity);
+
+    BigDecimal calculateDiscountPrice(Order order, MarketingActivity activity);
+
+    BigDecimal calculateFullReduction(Order order, MarketingActivity activity);
+
+    List<MarketingActivity> getApplicableActivities(Order order);
+
+    boolean isApplicableToShop(MarketingActivity activity, Long shopId);
+
+    boolean isApplicableToDevice(MarketingActivity activity, String deviceId);
+
+    boolean isApplicableToProduct(MarketingActivity activity, Long productId);
+
+    boolean isFirstOrder(Long userId);
+
+    Map<String, Object> calculateOrderDiscount(Order order);
+}

+ 2 - 0
haha-service/src/main/java/com/haha/service/TimedDiscountService.java

@@ -30,6 +30,8 @@ public interface TimedDiscountService extends IService<TimedDiscountActivity> {
 
     List<Long> getActivityShopIds(Long activityId);
 
+    List<String> getActivityDeviceIds(Long activityId);
+
     List<Long> getActivityProductIds(Long activityId);
 
     List<TimedDiscountActivity> getEnabledActivities();

+ 25 - 0
haha-service/src/main/java/com/haha/service/impl/MarketingActivityServiceImpl.java

@@ -9,12 +9,14 @@ import com.haha.common.enums.ActivityTypeEnum;
 import com.haha.common.enums.ApplyScopeEnum;
 import com.haha.common.exception.BusinessException;
 import com.haha.common.vo.StatusLabel;
+import com.haha.entity.ActivityDevice;
 import com.haha.entity.ActivityProduct;
 import com.haha.entity.ActivityShop;
 import com.haha.entity.MarketingActivity;
 import com.haha.entity.dto.ActivityCreateDTO;
 import com.haha.entity.dto.ActivityQueryDTO;
 import com.haha.entity.dto.ActivityUpdateDTO;
+import com.haha.mapper.ActivityDeviceMapper;
 import com.haha.mapper.ActivityProductMapper;
 import com.haha.mapper.ActivityShopMapper;
 import com.haha.mapper.MarketingActivityMapper;
@@ -38,6 +40,7 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
 
     private final MarketingActivityMapper activityMapper;
     private final ActivityShopMapper activityShopMapper;
+    private final ActivityDeviceMapper activityDeviceMapper;
     private final ActivityProductMapper activityProductMapper;
 
     @Override
@@ -85,6 +88,7 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
         activity.setMinAmount(dto.getMinAmount());
         activity.setMaxDiscount(dto.getMaxDiscount());
         activity.setApplyScope(dto.getApplyScope());
+        activity.setDeviceScope(dto.getDeviceScope());
         activity.setProductScope(dto.getProductScope());
         activity.setTotalBudget(dto.getTotalBudget());
         activity.setUsedBudget(BigDecimal.ZERO);
@@ -95,6 +99,7 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
         this.save(activity);
 
         saveActivityShops(activity.getId(), dto.getShopIds(), dto.getApplyScope());
+        saveActivityDevices(activity.getId(), dto.getDeviceIds(), dto.getDeviceScope());
         saveActivityProducts(activity.getId(), dto.getProductIds(), dto.getProductScope());
 
         log.info("创建营销活动成功: id={}, name={}", activity.getId(), activity.getActivityName());
@@ -121,14 +126,17 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
         activity.setMinAmount(dto.getMinAmount());
         activity.setMaxDiscount(dto.getMaxDiscount());
         activity.setApplyScope(dto.getApplyScope());
+        activity.setDeviceScope(dto.getDeviceScope());
         activity.setProductScope(dto.getProductScope());
         activity.setTotalBudget(dto.getTotalBudget());
 
         this.updateById(activity);
 
         activityShopMapper.deleteByActivityId(activity.getId());
+        activityDeviceMapper.deleteByActivityId(activity.getId());
         activityProductMapper.deleteByActivityId(activity.getId());
         saveActivityShops(activity.getId(), dto.getShopIds(), dto.getApplyScope());
+        saveActivityDevices(activity.getId(), dto.getDeviceIds(), dto.getDeviceScope());
         saveActivityProducts(activity.getId(), dto.getProductIds(), dto.getProductScope());
 
         log.info("更新营销活动成功: id={}", activity.getId());
@@ -234,6 +242,11 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
         return activityShopMapper.selectShopIdsByActivityId(activityId);
     }
 
+    @Override
+    public List<String> getActivityDeviceIds(Long activityId) {
+        return activityDeviceMapper.selectDeviceIdsByActivityId(activityId);
+    }
+
     @Override
     public List<Long> getActivityProductIds(Long activityId) {
         return activityProductMapper.selectProductIdsByActivityId(activityId);
@@ -260,6 +273,18 @@ public class MarketingActivityServiceImpl extends ServiceImpl<MarketingActivityM
         }
     }
 
+    private void saveActivityDevices(Long activityId, List<String> deviceIds, Integer deviceScope) {
+        if (deviceScope != null && deviceScope == 2
+                && deviceIds != null && !deviceIds.isEmpty()) {
+            for (String deviceId : deviceIds) {
+                ActivityDevice activityDevice = new ActivityDevice();
+                activityDevice.setActivityId(activityId);
+                activityDevice.setDeviceId(deviceId);
+                activityDeviceMapper.insert(activityDevice);
+            }
+        }
+    }
+
     private void saveActivityProducts(Long activityId, List<Long> productIds, Integer productScope) {
         if (productScope != null && productScope == 2
                 && productIds != null && !productIds.isEmpty()) {

+ 219 - 0
haha-service/src/main/java/com/haha/service/impl/MarketingRuleServiceImpl.java

@@ -0,0 +1,219 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.haha.common.enums.ActivityStatusEnum;
+import com.haha.common.enums.ActivityTypeEnum;
+import com.haha.common.enums.ApplyScopeEnum;
+import com.haha.entity.MarketingActivity;
+import com.haha.entity.Order;
+import com.haha.mapper.ActivityDeviceMapper;
+import com.haha.mapper.ActivityProductMapper;
+import com.haha.mapper.ActivityShopMapper;
+import com.haha.mapper.MarketingActivityMapper;
+import com.haha.mapper.OrderMapper;
+import com.haha.service.MarketingRuleService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MarketingRuleServiceImpl implements MarketingRuleService {
+
+    private final MarketingActivityMapper activityMapper;
+    private final ActivityShopMapper activityShopMapper;
+    private final ActivityDeviceMapper activityDeviceMapper;
+    private final ActivityProductMapper activityProductMapper;
+    private final OrderMapper orderMapper;
+
+    @Override
+    public BigDecimal calculateDiscount(Long orderId, Long activityId) {
+        Order order = orderMapper.selectById(orderId);
+        if (order == null) {
+            return BigDecimal.ZERO;
+        }
+
+        MarketingActivity activity = activityMapper.selectById(activityId);
+        if (activity == null || activity.getStatus() != ActivityStatusEnum.ONGOING.getCode()) {
+            return BigDecimal.ZERO;
+        }
+
+        if (!isApplicableToDevice(activity, order.getDeviceId())) {
+            return BigDecimal.ZERO;
+        }
+
+        switch (activity.getActivityType()) {
+            case 1:
+                return calculateFirstOrderDiscount(order, activity);
+            case 2:
+                return calculateDiscountPrice(order, activity);
+            case 3:
+                return calculateFullReduction(order, activity);
+            default:
+                return BigDecimal.ZERO;
+        }
+    }
+
+    @Override
+    public BigDecimal calculateFirstOrderDiscount(Order order, MarketingActivity activity) {
+        if (!isFirstOrder(order.getUserId())) {
+            log.info("用户非首单,不适用首单立减活动: userId={}", order.getUserId());
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal discount = activity.getDiscountValue();
+        if (discount == null || discount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal orderAmount = order.getTotalAmount();
+        if (orderAmount.compareTo(discount) < 0) {
+            return orderAmount;
+        }
+
+        return discount;
+    }
+
+    @Override
+    public BigDecimal calculateDiscountPrice(Order order, MarketingActivity activity) {
+        BigDecimal discountRate = activity.getDiscountValue();
+        if (discountRate == null || discountRate.compareTo(BigDecimal.ZERO) <= 0 
+                || discountRate.compareTo(BigDecimal.ONE) > 0) {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal orderAmount = order.getTotalAmount();
+        BigDecimal discountAmount = orderAmount.multiply(BigDecimal.ONE.subtract(discountRate))
+                .setScale(2, RoundingMode.HALF_UP);
+
+        if (activity.getMaxDiscount() != null && activity.getMaxDiscount().compareTo(BigDecimal.ZERO) > 0) {
+            if (discountAmount.compareTo(activity.getMaxDiscount()) > 0) {
+                discountAmount = activity.getMaxDiscount();
+            }
+        }
+
+        return discountAmount;
+    }
+
+    @Override
+    public BigDecimal calculateFullReduction(Order order, MarketingActivity activity) {
+        BigDecimal minAmount = activity.getMinAmount();
+        BigDecimal discountValue = activity.getDiscountValue();
+
+        if (minAmount == null || discountValue == null || minAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal orderAmount = order.getTotalAmount();
+        if (orderAmount.compareTo(minAmount) < 0) {
+            log.info("订单金额未达满减门槛: orderAmount={}, minAmount={}", orderAmount, minAmount);
+            return BigDecimal.ZERO;
+        }
+
+        return discountValue;
+    }
+
+    @Override
+    public List<MarketingActivity> getApplicableActivities(Order order) {
+        LocalDateTime now = LocalDateTime.now();
+        List<MarketingActivity> activities = activityMapper.selectList(
+                new LambdaQueryWrapper<MarketingActivity>()
+                        .eq(MarketingActivity::getStatus, ActivityStatusEnum.ONGOING.getCode())
+                        .le(MarketingActivity::getStartTime, now)
+                        .ge(MarketingActivity::getEndTime, now)
+                        .eq(MarketingActivity::getDeleted, 0)
+        );
+
+        return activities.stream()
+                .filter(activity -> isApplicableToDevice(activity, order.getDeviceId()))
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public boolean isApplicableToShop(MarketingActivity activity, Long shopId) {
+        if (activity.getApplyScope() == null || activity.getApplyScope() == ApplyScopeEnum.ALL_SHOP.getCode()) {
+            return true;
+        }
+
+        List<Long> shopIds = activityShopMapper.selectShopIdsByActivityId(activity.getId());
+        return shopIds.contains(shopId);
+    }
+
+    @Override
+    public boolean isApplicableToDevice(MarketingActivity activity, String deviceId) {
+        if (activity.getDeviceScope() == null || activity.getDeviceScope() == 1) {
+            return true;
+        }
+
+        List<String> deviceIds = activityDeviceMapper.selectDeviceIdsByActivityId(activity.getId());
+        return deviceIds.contains(deviceId);
+    }
+
+    @Override
+    public boolean isApplicableToProduct(MarketingActivity activity, Long productId) {
+        if (activity.getProductScope() == null || activity.getProductScope() == 1) {
+            return true;
+        }
+
+        List<Long> productIds = activityProductMapper.selectProductIdsByActivityId(activity.getId());
+        return productIds.contains(productId);
+    }
+
+    @Override
+    public boolean isFirstOrder(Long userId) {
+        Long orderCount = orderMapper.selectCount(
+                new LambdaQueryWrapper<Order>()
+                        .eq(Order::getUserId, userId)
+                        .in(Order::getStatus, List.of(1, 2, 3))
+        );
+        return orderCount == null || orderCount == 0;
+    }
+
+    @Override
+    public Map<String, Object> calculateOrderDiscount(Order order) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("originalAmount", order.getTotalAmount());
+        result.put("totalDiscount", BigDecimal.ZERO);
+        result.put("activities", new ArrayList<>());
+
+        List<MarketingActivity> applicableActivities = getApplicableActivities(order);
+        BigDecimal totalDiscount = BigDecimal.ZERO;
+        List<Map<String, Object>> appliedActivities = new ArrayList<>();
+
+        for (MarketingActivity activity : applicableActivities) {
+            BigDecimal discount = calculateDiscount(order.getId(), activity.getId());
+            if (discount.compareTo(BigDecimal.ZERO) > 0) {
+                totalDiscount = totalDiscount.add(discount);
+                
+                Map<String, Object> activityInfo = new HashMap<>();
+                activityInfo.put("id", activity.getId());
+                activityInfo.put("name", activity.getActivityName());
+                activityInfo.put("type", activity.getActivityType());
+                activityInfo.put("typeLabel", ActivityTypeEnum.getLabelByCode(activity.getActivityType()).getLabel());
+                activityInfo.put("discount", discount);
+                appliedActivities.add(activityInfo);
+            }
+        }
+
+        BigDecimal finalAmount = order.getTotalAmount().subtract(totalDiscount);
+        if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
+            finalAmount = BigDecimal.ZERO;
+        }
+
+        result.put("totalDiscount", totalDiscount);
+        result.put("finalAmount", finalAmount);
+        result.put("activities", appliedActivities);
+
+        return result;
+    }
+}

+ 0 - 1
haha-service/src/main/java/com/haha/service/impl/OrderServiceImpl.java

@@ -1,7 +1,6 @@
 package com.haha.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;

+ 33 - 0
haha-service/src/main/java/com/haha/service/impl/TimedDiscountServiceImpl.java

@@ -36,6 +36,7 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
 
     private final TimedDiscountActivityMapper activityMapper;
     private final TimedDiscountShopMapper discountShopMapper;
+    private final TimedDiscountDeviceMapper discountDeviceMapper;
     private final TimedDiscountProductMapper discountProductMapper;
     private final TimedDiscountRecordMapper recordMapper;
     private final PriceAdjustmentLogMapper priceAdjustmentLogMapper;
@@ -94,6 +95,7 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         activity.setDiscountValue(dto.getDiscountValue());
         activity.setMinDiscountPrice(dto.getMinDiscountPrice());
         activity.setApplyScope(dto.getApplyScope());
+        activity.setDeviceScope(dto.getDeviceScope() != null ? dto.getDeviceScope() : 1);
         activity.setProductScope(dto.getProductScope());
         activity.setProductTypes(dto.getProductTypes());
         activity.setPriorityProductTypes(dto.getPriorityProductTypes());
@@ -109,6 +111,7 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         this.save(activity);
 
         saveActivityShops(activity.getId(), dto.getShopIds(), dto.getApplyScope());
+        saveActivityDevices(activity.getId(), dto.getDeviceIds(), dto.getDeviceScope());
         saveActivityProducts(activity.getId(), dto.getProductIds(), dto.getProductScope());
 
         log.info("创建定时折扣活动成功: id={}, name={}", activity.getId(), activity.getActivityName());
@@ -131,6 +134,7 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         activity.setDiscountValue(dto.getDiscountValue());
         activity.setMinDiscountPrice(dto.getMinDiscountPrice());
         activity.setApplyScope(dto.getApplyScope());
+        activity.setDeviceScope(dto.getDeviceScope());
         activity.setProductScope(dto.getProductScope());
         activity.setProductTypes(dto.getProductTypes());
         activity.setPriorityProductTypes(dto.getPriorityProductTypes());
@@ -142,8 +146,10 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         this.updateById(activity);
 
         deleteActivityShops(activity.getId());
+        deleteActivityDevices(activity.getId());
         deleteActivityProducts(activity.getId());
         saveActivityShops(activity.getId(), dto.getShopIds(), dto.getApplyScope());
+        saveActivityDevices(activity.getId(), dto.getDeviceIds(), dto.getDeviceScope());
         saveActivityProducts(activity.getId(), dto.getProductIds(), dto.getProductScope());
 
         log.info("更新定时折扣活动成功: id={}", activity.getId());
@@ -195,6 +201,15 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
                 .collect(Collectors.toList());
     }
 
+    @Override
+    public List<String> getActivityDeviceIds(Long activityId) {
+        LambdaQueryWrapper<TimedDiscountDevice> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(TimedDiscountDevice::getActivityId, activityId);
+        return discountDeviceMapper.selectList(wrapper).stream()
+                .map(TimedDiscountDevice::getDeviceId)
+                .collect(Collectors.toList());
+    }
+
     @Override
     public List<Long> getActivityProductIds(Long activityId) {
         LambdaQueryWrapper<TimedDiscountProduct> wrapper = new LambdaQueryWrapper<>();
@@ -603,6 +618,18 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         }
     }
 
+    private void saveActivityDevices(Long activityId, List<String> deviceIds, Integer deviceScope) {
+        if (deviceScope != null && deviceScope == 2
+                && deviceIds != null && !deviceIds.isEmpty()) {
+            for (String deviceId : deviceIds) {
+                TimedDiscountDevice discountDevice = new TimedDiscountDevice();
+                discountDevice.setActivityId(activityId);
+                discountDevice.setDeviceId(deviceId);
+                discountDeviceMapper.insert(discountDevice);
+            }
+        }
+    }
+
     private void saveActivityProducts(Long activityId, List<Long> productIds, Integer productScope) {
         if (productScope != null && productScope == 3
                 && productIds != null && !productIds.isEmpty()) {
@@ -621,6 +648,12 @@ public class TimedDiscountServiceImpl extends ServiceImpl<TimedDiscountActivityM
         discountShopMapper.delete(wrapper);
     }
 
+    private void deleteActivityDevices(Long activityId) {
+        LambdaQueryWrapper<TimedDiscountDevice> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(TimedDiscountDevice::getActivityId, activityId);
+        discountDeviceMapper.delete(wrapper);
+    }
+
     private void deleteActivityProducts(Long activityId) {
         LambdaQueryWrapper<TimedDiscountProduct> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(TimedDiscountProduct::getActivityId, activityId);