Browse Source

Merge remote-tracking branch 'origin/master'

zhujing 1 tháng trước cách đây
mục cha
commit
c37cb41a08
14 tập tin đã thay đổi với 325 bổ sung và 93 xóa
  1. 6 15
      java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/mapper/AiMessageMapper.java
  2. 2 0
      java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/service/impl/AiMessageReviewServiceImpl.java
  3. 14 15
      java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/service/impl/AiMessageServiceImpl.java
  4. 39 26
      java/storlead-knowledge/storlead-knowledge-biz/src/main/resources/mapper/AiMessageMapper.xml
  5. 1 9
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingController.java
  6. 5 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignDTO.java
  7. 2 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketingCampaignEntity.java
  8. 2 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketingCampaignService.java
  9. 181 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignServiceImpl.java
  10. 31 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/CampaignStatsVO.java
  11. 10 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketingCampaignDetailVO.java
  12. 16 12
      ui/smarttrade-platform/src/components/addMarketing.vue
  13. 14 10
      ui/smarttrade-platform/src/components/marketing/marketingDetail.vue
  14. 2 2
      ui/smarttrade-platform/src/views/MarketingView.vue

+ 6 - 15
java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/mapper/AiMessageMapper.java

@@ -1,9 +1,6 @@
 package com.storlead.knowledge.mapper;
 
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.core.toolkit.Constants;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.storlead.framework.mybatis.mapper.MyBaseMapper;
 import com.storlead.knowledge.entity.AiMessageEntity;
 import com.storlead.knowledge.pojo.vo.AiMessageWithReviewVO;
@@ -21,17 +18,11 @@ import java.util.List;
  */
 public interface AiMessageMapper extends MyBaseMapper<AiMessageEntity> {
 
-    /**
-     * 分页查询聊天记录(含好评/差评总数),LEFT JOIN ai_message_review 表
-     *
-     * @param page    分页参数
-     * @param wrapper 查询条件(由 MyBatis-Plus 自动构建)
-     * @return 分页结果(含 agree / disagree 字段)
-     */
-    IPage<AiMessageWithReviewVO> pageQueryWithReview(IPage<?> page,
-                                                     @Param(Constants.WRAPPER) Wrapper<AiMessageEntity> wrapper);
 
-    List<AiMessageWithReviewVO> queryList(IPage<?> page, @Param(Constants.WRAPPER) Wrapper<AiMessageEntity> wrapper);
+    List<AiMessageWithReviewVO> queryList(  @Param("page") Page<AiMessageEntity> page,
+                                            @Param("keyword") String keyword,
+                                            @Param("queryField") String queryField);
 
-    Integer getTotal( @Param(Constants.WRAPPER)LambdaQueryWrapper<AiMessageEntity> wrapper);
+    Integer getTotal(  @Param("keyword") String keyword,
+                       @Param("queryField") String queryField);
 }

+ 2 - 0
java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/service/impl/AiMessageReviewServiceImpl.java

@@ -2,6 +2,7 @@ package com.storlead.knowledge.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
+import com.storlead.framework.util.LoginUserUtil;
 import com.storlead.knowledge.entity.AiMessageReviewEntity;
 import com.storlead.knowledge.mapper.AiMessageReviewMapper;
 import com.storlead.knowledge.service.AiMessageReviewService;
@@ -22,6 +23,7 @@ public class AiMessageReviewServiceImpl extends MyBaseServiceImpl<AiMessageRevie
     public AiMessageReviewEntity getByAiMessageId(Long aiMessageId) {
         LambdaQueryWrapper<AiMessageReviewEntity> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AiMessageReviewEntity::getAiMessageId, aiMessageId);
+        wrapper.eq(AiMessageReviewEntity::getOwnerBy, LoginUserUtil.getLoginUser().getId());
         return baseMapper.selectOne(wrapper);
     }
 

+ 14 - 15
java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/service/impl/AiMessageServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.knowledge.entity.AiMessageEntity;
+import com.storlead.knowledge.entity.AiMessageReviewEntity;
 import com.storlead.knowledge.mapper.AiMessageMapper;
 import com.storlead.knowledge.pojo.dto.QueryPageDTO;
 import com.storlead.knowledge.pojo.vo.AiMessageWithReviewVO;
@@ -47,22 +48,20 @@ public class AiMessageServiceImpl extends MyBaseServiceImpl<AiMessageMapper, AiM
 
     @Override
     public IPage<AiMessageWithReviewVO> pageQueryWithReview(QueryPageDTO page) {
-        // 构建分页参数
-        Page<AiMessageEntity> pageParam = new Page<>(page.getPageIndex(), page.getPageSize());
+        Page<AiMessageEntity> pageParam =
+                new Page<>(page.getPageIndex(), page.getPageSize());
+
+        String queryField = StringUtils.isNotBlank(page.getQuery()) ? page.getQuery() : null;
+
+        List<AiMessageWithReviewVO> list =
+                baseMapper.queryList(pageParam, page.getKeyword(), queryField);
+
+        Integer total =
+                baseMapper.getTotal(page.getKeyword(), queryField);
+
+        Page<AiMessageWithReviewVO> result =
+                new Page<>(page.getPageIndex(), page.getPageSize());
 
-        // 构建查询条件(与 pageQuery 一致)
-        LambdaQueryWrapper<AiMessageEntity> wrapper = new LambdaQueryWrapper<>();
-        if (StringUtils.isNotBlank(page.getKeyword())) {
-            wrapper.and(w -> w
-                    .like(AiMessageEntity::getQuery, page.getKeyword())
-                    .or()
-                    .like(AiMessageEntity::getAnswer, page.getKeyword())
-            );
-        }
-        List<AiMessageWithReviewVO> list =  baseMapper.queryList(pageParam, wrapper);
-        Integer total = baseMapper.getTotal(wrapper);
-        // 直接调用 Mapper 的 LEFT JOIN 分页查询
-        Page<AiMessageWithReviewVO> result = new Page<>(page.getPageIndex(), page.getPageSize());
         result.setRecords(list);
         result.setTotal(total);
         result.setPages(page.getPageSize());

+ 39 - 26
java/storlead-knowledge/storlead-knowledge-biz/src/main/resources/mapper/AiMessageMapper.xml

@@ -19,38 +19,51 @@
         <result column="agree" property="agree"/>
         <result column="disagree" property="disagree"/>
     </resultMap>
-
-    <!-- 分页查询聊天记录(含评价信息) -->
-    <select id="pageQueryWithReview" resultMap="AiMessageWithReviewMap">
-        SELECT
-            m.*,
-            COALESCE(sum(r.agree, 0)) AS agree,
-            COALESCE(sum(r.disagree, 0)) AS disagree
-        FROM ai_messages m
-        LEFT JOIN ai_messages_review r ON r.ai_message_id = m.id
-        ${ew.customSqlSegment}
-        group by  m.id
-    </select>
-
     <select id="queryList" resultMap="AiMessageWithReviewMap">
         SELECT
-            m.*,
-            IFNULL(sum(r.agree), 0) AS agree,
-            IFNULL(sum(r.disagree), 0) AS disagree
+        m.id,
+        m.query,
+        m.answer,
+        IFNULL(sum(r.agree), 0) AS agree,
+        IFNULL(sum(r.disagree), 0) AS disagree
         FROM ai_messages m
-                 LEFT JOIN ai_messages_review r ON r.ai_message_id = m.id
-            ${ew.customSqlSegment}
-        group by  m.id
+        LEFT JOIN ai_messages_review r ON r.ai_message_id = m.id
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                m.query LIKE CONCAT('%', #{keyword}, '%')
+                OR m.answer LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+        GROUP BY m.id
+        <if test="queryField != null and queryField != ''">
+            HAVING ${queryField} > 0
+        </if>
     </select>
 
-
-    <select id="getTotal"  resultType="java.lang.Integer">
-        SELECT  count(DISTINCT m.id) as total
+    <select id="getTotal" resultType="java.lang.Integer">
+        SELECT COUNT(DISTINCT id)
+        FROM (SELECT
+        m.id,
+        m.query,
+        m.answer,
+        IFNULL(sum(r.agree), 0) AS agree,
+        IFNULL(sum(r.disagree), 0) AS disagree
         FROM ai_messages m
-                 LEFT JOIN ai_messages_review r ON r.ai_message_id = m.id
-            ${ew.customSqlSegment}
-
+        LEFT JOIN ai_messages_review r ON r.ai_message_id = m.id
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                m.query LIKE CONCAT('%', #{keyword}, '%')
+                OR m.answer LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+        GROUP BY m.id
+        <if test="queryField != null and queryField != ''">
+            HAVING ${queryField} > 0
+        </if>) total
     </select>
 
-
 </mapper>

+ 1 - 9
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingController.java

@@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.util.HashMap;
 import java.util.List;
 
 @RestController
@@ -69,14 +68,7 @@ public class MarketingController {
     @GetMapping("/dashboard/stats")
     @ApiOperation("营销: 仪表盘统计数据")
     public Result<Object> dashboardStats() {
-        return Result.result(new HashMap<String, Object>() {{
-            put("monthlySent", "2.8K");
-            put("avgOpenRate", "45%");
-            put("clickRate", "12%");
-            put("replyRate", "8%");
-            put("protectedCustomers", 156);
-            put("pendingReview", 23);
-        }});
+        return marketingService.getDashboardStats();
     }
 
     @GetMapping("/detail/{id}")

+ 5 - 2
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignDTO.java

@@ -48,9 +48,12 @@ public class MarketingCampaignDTO extends Page {
     @TableField("end_date")
     private LocalDate endDate;
 
-    @ApiModelProperty(value = "负责人id")
+    @ApiModelProperty(value = "负责人id(逗号分隔字符串,如1,2,3)")
     @TableField("supervisor")
-    private Long supervisor;
+    private String supervisor;
+
+    @ApiModelProperty(value = "负责人id列表(前端传参用)")
+    private List<Long> supervisorList;
 
     @ApiModelProperty(value = "关联商机")
     @TableField("link_to_opportunity")

+ 2 - 2
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketingCampaignEntity.java

@@ -46,9 +46,9 @@ public class MarketingCampaignEntity extends SysBaseField {
     @TableField("end_date")
     private LocalDate endDate;
 
-    @ApiModelProperty(value = "负责人id")
+    @ApiModelProperty(value = "负责人id列表(逗号分隔字符串)")
     @TableField("supervisor")
-    private Long supervisor;
+    private String supervisor;
 
     @ApiModelProperty(value = "关联商机")
     @TableField("link_to_opportunity")

+ 2 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketingCampaignService.java

@@ -22,4 +22,6 @@ public interface MarketingCampaignService extends MyBaseService<MarketingCampaig
 
     Result<Object> updateStatus(Long id, Integer status);
 
+    Result<Object> getDashboardStats();
+
 }

+ 181 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignServiceImpl.java

@@ -12,7 +12,9 @@ import com.storlead.trade.dto.MarketingCampaignDTO;
 import com.storlead.trade.entity.*;
 import com.storlead.trade.mapper.MarketingCampaignEntityMapper;
 import com.storlead.trade.service.*;
+import com.storlead.trade.vo.CampaignStatsVO;
 import com.storlead.trade.vo.MarketingCampaignDetailVO;
+import com.storlead.user.service.IUserService;
 import com.storlead.trade.vo.MarketingCampaignVO;
 import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.result.Result;
@@ -26,7 +28,11 @@ import org.springframework.util.ObjectUtils;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 
 @Service
@@ -49,6 +55,8 @@ public class MarketingCampaignServiceImpl
     private MarketEmailsService marketEmailsService;
     @Resource
     private CustomerTradeService customerTradeService;
+    @Resource
+    private IUserService userService;
 
     @Override
     public Result<Object> getList(MarketingCampaignDTO marketingCampaignDTO) {
@@ -165,6 +173,53 @@ public class MarketingCampaignServiceImpl
         detailVO.setSopDetailList(relationList);
         detailVO.setCustomerList(customerEntityList);
 
+        // 3.1 supervisor 联动:解析为id列表 + 查user表
+        List<Long> supervisorIdList = parseSupervisorList(campaign.getSupervisor());
+        detailVO.setSupervisorList(supervisorIdList);
+        if (!CollectionUtils.isEmpty(supervisorIdList)) {
+            detailVO.setSupervisorUserList(userService.getUserListByIds(supervisorIdList));
+        }
+
+        // 4. 计算达成率统计(从 market_emails 表取实际数据)
+        CampaignStatsVO stats = new CampaignStatsVO();
+        Integer sendVolume = campaign.getSendVolume();
+        stats.setSendVolume(sendVolume);
+
+        Integer sentCountLong = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getMarketingCampaignId, id)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getStatus, 1)
+                .count();
+        Integer sentCount = sentCountLong == null ? 0 : sentCountLong.intValue();
+        stats.setSentCount(sentCount);
+
+        Integer openCountLong = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getMarketingCampaignId, id)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getHasReader, 1)
+                .count();
+        Integer openCount = openCountLong == null ? 0 : openCountLong.intValue();
+        stats.setOpenCount(openCount);
+
+        Integer replyCountLong = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getMarketingCampaignId, id)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getHasReply, 1)
+                .count();
+        Integer replyCount = replyCountLong == null ? 0 : replyCountLong.intValue();
+        stats.setReplyCount(replyCount);
+
+        // 打开率 = openCount / sentCount
+        stats.setOpenRate(sentCount > 0 ? Math.round(openCount * 100f / sentCount) : 0);
+        // 回复率 = replyCount / sentCount
+        stats.setReplyRate(sentCount > 0 ? Math.round(replyCount * 100f / sentCount) : 0);
+        // 达成率 = sentCount / sendVolume
+        stats.setAchievementRate(sendVolume != null && sendVolume > 0
+                ? Math.min(Math.round(sentCount * 100f / sendVolume), 100)
+                : 0);
+
+        detailVO.setStats(stats);
+
         return Result.result(detailVO);
     }
 
@@ -173,6 +228,8 @@ public class MarketingCampaignServiceImpl
     public Result<Object> addMarket(MarketingCampaignDTO marketingCampaignDTO) {
         MarketingCampaignEntity marketingCampaignEntity = new MarketingCampaignEntity();
         BeanUtils.copyProperties(marketingCampaignDTO, marketingCampaignEntity);
+        // supervisorList 转为逗号分隔字符串
+        marketingCampaignEntity.setSupervisor(joinSupervisorList(marketingCampaignDTO));
         // 过滤 AI 最优时间占位值,避免写入 DATETIME 列报错
         if ("optimal".equalsIgnoreCase(marketingCampaignEntity.getEmailSentTime())) {
             marketingCampaignEntity.setEmailSentTime(null);
@@ -237,6 +294,8 @@ public class MarketingCampaignServiceImpl
         MarketingCampaignEntity marketingCampaignOld = this.getById(marketingCampaignDTO.getId());
         MarketingCampaignEntity marketingCampaignEntity = new MarketingCampaignEntity();
         BeanUtils.copyProperties(marketingCampaignDTO, marketingCampaignEntity);
+        // supervisorList 转为逗号分隔字符串
+        marketingCampaignEntity.setSupervisor(joinSupervisorList(marketingCampaignDTO));
         // 过滤 AI 最优时间占位值,避免写入 DATETIME 列报错
         if ("optimal".equalsIgnoreCase(marketingCampaignEntity.getEmailSentTime())) {
             marketingCampaignEntity.setEmailSentTime(null);
@@ -316,4 +375,126 @@ public class MarketingCampaignServiceImpl
         this.updateById(entity);
         return Result.ok();
     }
+
+    @Override
+    public Result<Object> getDashboardStats() {
+        // 计算本月起止时间
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.DAY_OF_MONTH, 1);
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date monthStart = cal.getTime();
+
+        // 本月已发送数(market_emails.status=1)
+        Integer sentCount = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getStatus, 1)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .ge(MarketEmailsEntity::getCreateTime, monthStart)
+                .count();
+
+        // 本月打开数
+        Integer openCount = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getStatus, 1)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .ge(MarketEmailsEntity::getCreateTime, monthStart)
+                .eq(MarketEmailsEntity::getHasReader, 1)
+                .count();
+
+        // 本月回复数
+        Integer replyCount = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getStatus, 1)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .ge(MarketEmailsEntity::getCreateTime, monthStart)
+                .eq(MarketEmailsEntity::getHasReply, 1)
+                .count();
+
+        int sent = sentCount == null ? 0 : sentCount.intValue();
+        int open = openCount == null ? 0 : openCount.intValue();
+        int reply = replyCount == null ? 0 : replyCount.intValue();
+
+        int avgOpenRate = sent > 0 ? Math.round(open * 100f / sent) : 0;
+        int replyRate = sent > 0 ? Math.round(reply * 100f / sent) : 0;
+        // TODO: market_emails 表未加 has_clicked 字段,点击率暂返回 0,添加后替换为 hasClick=1 统计
+        int clickRate = 0;
+
+        // 受保护客户数(distinct customer_id where has_protect=1)
+        List<MarketEmailsEntity> protectedList = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getHasProtect, 1)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .isNotNull(MarketEmailsEntity::getCustomerId)
+                .select(MarketEmailsEntity::getCustomerId)
+                .list();
+        long protectedCustomers = protectedList.stream()
+                .map(MarketEmailsEntity::getCustomerId)
+                .filter(java.util.Objects::nonNull)
+                .distinct()
+                .count();
+
+        // 待审核营销活动数(status=0 且未软删)
+        Integer pendingCount = this.lambdaQuery()
+                .eq(MarketingCampaignEntity::getStatus, 0)
+                .eq(MarketingCampaignEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .count();
+
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("monthlySent", formatCount(sent));
+        stats.put("avgOpenRate", avgOpenRate + "%");
+        // TODO: market_emails 表未加 has_clicked 字段,点击率暂返回 0%,添加后替换为 hasClick=1 统计
+        stats.put("clickRate", clickRate + "%");
+        stats.put("replyRate", replyRate + "%");
+        stats.put("protectedCustomers", String.valueOf((int) protectedCustomers));
+        stats.put("pendingReview", String.valueOf(pendingCount == null ? 0 : pendingCount.intValue()));
+
+        return Result.result(stats);
+    }
+
+    /**
+     * 格式化计数显示:<1000 原样, 1000-9999 转为 "X.XK", >=10000 转为 "X.XW"
+     */
+    private String formatCount(int n) {
+        if (n < 1000) {
+            return String.valueOf(n);
+        }
+        if (n < 10000) {
+            return String.format("%.1fK", n / 1000.0);
+        }
+        return String.format("%.1fW", n / 10000.0);
+    }
+
+    /**
+     * supervisorList 转逗号分隔字符串。如未传 supervisorList 则取原 supervisor 字符串。
+     */
+    private String joinSupervisorList(MarketingCampaignDTO dto) {
+        if (dto == null) {
+            return null;
+        }
+        if (!CollectionUtils.isEmpty(dto.getSupervisorList())) {
+            return dto.getSupervisorList().stream()
+                    .filter(java.util.Objects::nonNull)
+                    .map(String::valueOf)
+                    .collect(Collectors.joining(","));
+        }
+        return dto.getSupervisor();
+    }
+
+    /**
+     * 解析逗号分隔的 supervisor 字符串为 id 列表。
+     */
+    private List<Long> parseSupervisorList(String supervisor) {
+        if (supervisor == null || supervisor.trim().isEmpty()) {
+            return new ArrayList<>();
+        }
+        List<Long> ids = new ArrayList<>();
+        for (String s : supervisor.split(",")) {
+            if (!s.trim().isEmpty()) {
+                try {
+                    ids.add(Long.parseLong(s.trim()));
+                } catch (NumberFormatException ignore) {
+                }
+            }
+        }
+        return ids;
+    }
 }

+ 31 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/CampaignStatsVO.java

@@ -0,0 +1,31 @@
+package com.storlead.trade.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "CampaignStatsVO", description = "营销活动达成率统计")
+public class CampaignStatsVO {
+
+    @ApiModelProperty(value = "计划发送量(来自营销活动sendVolume)")
+    private Integer sendVolume;
+
+    @ApiModelProperty(value = "实际已发送数(market_emails.status=1)")
+    private Integer sentCount;
+
+    @ApiModelProperty(value = "打开数(market_emails.hasReader=1)")
+    private Integer openCount;
+
+    @ApiModelProperty(value = "回复数(market_emails.hasReply=1)")
+    private Integer replyCount;
+
+    @ApiModelProperty(value = "打开率(0-100)")
+    private Integer openRate;
+
+    @ApiModelProperty(value = "回复率(0-100)")
+    private Integer replyRate;
+
+    @ApiModelProperty(value = "达成率(0-100)")
+    private Integer achievementRate;
+}

+ 10 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketingCampaignDetailVO.java

@@ -1,6 +1,7 @@
 package com.storlead.trade.vo;
 
 import com.storlead.trade.entity.*;
+import com.storlead.user.pojo.entity.UserEntity;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -22,4 +23,13 @@ public class MarketingCampaignDetailVO {
 
     @ApiModelProperty(value = "关联的sop详情")
     private SopEntity sopEntity;
+
+    @ApiModelProperty(value = "达成率统计(计划/已发送/打开/回复 + 三个率)")
+    private CampaignStatsVO stats;
+
+    @ApiModelProperty(value = "负责人id列表")
+    private List<Long> supervisorList;
+
+    @ApiModelProperty(value = "负责人用户详情列表")
+    private List<UserEntity> supervisorUserList;
 }

+ 16 - 12
ui/smarttrade-platform/src/components/addMarketing.vue

@@ -757,7 +757,7 @@
         },
         goals: { sent: 0, openRate: 0, replyRate: 0, opportunities: 0 },
         channels: ['email'],
-        emailSender: '', emailSendTime: 'optimal',
+        emailSender: 'fan@storlead.com', emailSendTime: 'optimal',
         trackOpen: true, trackClick: true, trackReply: true,
         whatsappAccount: '业务账号1', whatsappInterval: '60', whatsappTemplate: '',
         linkedinAction: 'message', linkedinLimit: '30',
@@ -904,10 +904,6 @@
         try {
             const typeMap = { prospect: 1, nurture: 2, reactivate: 3, promote: 4, seasonal: 5, exhibition: 6, other: 7 }
             const priorityMap = { urgent: 1, high: 2, medium: 3, low: 4 }
-            const ownerMap = { sales1: 1, sales2: 2, sales3: 3 }
-            const selectedOwners = Array.isArray(newCampaign.value.owner)
-                ? newCampaign.value.owner.filter(Boolean)
-                : [newCampaign.value.owner].filter(Boolean)
             const channelConfig = newCampaign.value.channels.reduce((acc, channel) => {
                 if (channel === 'email') return acc + 1
                 if (channel === 'whatsapp') return acc + 2
@@ -922,7 +918,7 @@
                 description: newCampaign.value.description,
                 beginData: newCampaign.value.startDate || null,
                 endDate: newCampaign.value.endDate || null,
-                supervisor: selectedOwners.length ? ownerMap[selectedOwners[0]] || null : null,
+                supervisorList: newCampaign.value.owner,
                 linkToOpportunity: newCampaign.value.relatedOpportunity || null,
                 sendVolume: Number(newCampaign.value.goals.sent) || null,
                 openRate: Number(newCampaign.value.goals.openRate) || null,
@@ -948,14 +944,22 @@
                 customerIdList: customerList.value.map(c => c.id)
             }
             if (isEditMode.value) {
-                await editMarketing(payload)
-                ElMessage.success('营销活动已更新')
+                await editMarketing(payload).then(res => {
+                    ElMessage.success('营销活动已更新')
+                    emits('refresh')
+                    closeCreateModal()
+                }).catch(err => {
+                    ElMessage.error(err?.message || '更新营销活动失败')
+                })
             } else {
-                await addMarketing(payload)
-                ElMessage.success('营销活动已创建')
+                await addMarketing(payload).then(res => {
+                    ElMessage.success('营销活动已创建')
+                    emits('refresh')
+                    closeCreateModal()
+                }).catch(err => {
+                    ElMessage.error(err?.message || '创建营销活动失败')
+                })
             }
-            emits('refresh')
-            closeCreateModal()
         } catch (error) {
             ElMessage.error(error?.message || '创建营销活动失败')
         }

+ 14 - 10
ui/smarttrade-platform/src/components/marketing/marketingDetail.vue

@@ -29,7 +29,7 @@
               </div>
               <div class="detail-item">
                 <label>负责人</label>
-                <span>{{ selectedCampaign.owner }}</span>
+                <span>{{ supervisorUser }}</span>
               </div>
               <div class="detail-item">
                 <label>创建时间</label>
@@ -50,31 +50,31 @@
           <div class="detail-section">
             <div class="section-header">
               <h4><i class="fas fa-chart-line"></i> 核心数据</h4>
-              <div class="progress-info">达成 {{ selectedCampaign.progressPercent || 0 }}%</div>
+              <div class="progress-info">达成 {{ statsData.achievementRate }}%</div>
             </div>
             <div class="detail-progress-bar">
-              <div class="progress-fill" :style="{ width: (selectedCampaign.progressPercent || 0) + '%' }"></div>
+              <div class="progress-fill" :style="{ width: (statsData.achievementRate) + '%' }"></div>
             </div>
             <div class="metrics-grid">
               <div class="metric-card">
                 <div class="metric-icon blue"><i class="fas fa-paper-plane"></i></div>
                 <div class="metric-content">
-                  <div class="metric-value">{{ formatNumber(selectedCampaign.sendVolume) }}</div>
+                  <div class="metric-value">{{ statsData.sentCount }}</div>
                   <div class="metric-label">已发送</div>
                 </div>
               </div>
               <div class="metric-card">
                 <div class="metric-icon green"><i class="fas fa-envelope-open"></i></div>
                 <div class="metric-content">
-                  <div class="metric-value">{{ Math.round(selectedCampaign.sendVolume * selectedCampaign.openRate / 100) }}</div>
-                  <div class="metric-label">打开数 ({{ selectedCampaign.openRate }}%)</div>
+                  <div class="metric-value">{{ statsData.openCount }}</div>
+                  <div class="metric-label">打开数 ({{ statsData.openRate }}%)</div>
                 </div>
               </div>
               <div class="metric-card">
                 <div class="metric-icon purple"><i class="fas fa-reply"></i></div>
                 <div class="metric-content">
-                  <div class="metric-value">{{ Math.round(selectedCampaign.sendVolume * selectedCampaign.replyRate / 100) }}</div>
-                  <div class="metric-label">回复数 ({{ selectedCampaign.replyRate }}%)</div>
+                  <div class="metric-value">{{ statsData.replyCount }}</div>
+                  <div class="metric-label">回复数 ({{ statsData.replyRate }}%)</div>
                 </div>
               </div>
             </div>
@@ -149,7 +149,7 @@
           </div>
 
           <!-- 最近互动 -->
-          <div class="detail-section">
+          <!-- <div class="detail-section">
             <div class="section-header">
               <h4><i class="fas fa-comments"></i> 最近互动</h4>
               <button class="btn-link" @click="showAllInteractions">查看全部</button>
@@ -196,7 +196,7 @@
                 </div>
               </div>
             </div>
-          </div>
+          </div> -->
         </div>
         <div class="modal-footer">
           <!-- <button class="btn btn-secondary" @click="duplicateCampaign(selectedCampaign)">
@@ -230,6 +230,8 @@
     const selectedCampaign = ref()
     const sopDetails = ref([])
     const customerData = ref([])
+    const statsData = ref()
+    const supervisorUser = ref()
     const open = (data) => {
         getData(data.id)
         showCampaignDetailModal.value = true
@@ -242,6 +244,8 @@
             sopDetails.value = res.result.sopDetailList || []
             customerData.value = res.result.customerList || []
             sopName.value = res.result.sopEntity.name
+            statsData.value = res.result.stats
+            supervisorUser.value = (res.result.supervisorUserList || []).map(item => item.realName).join(',')
         })
     }
 

+ 2 - 2
ui/smarttrade-platform/src/views/MarketingView.vue

@@ -34,13 +34,13 @@
           <p>平均打开率</p>
         </div>
       </div>
-      <div class="stat-card-lg">
+      <!-- <div class="stat-card-lg">
         <div class="stat-icon-lg purple"><i class="fas fa-mouse-pointer"></i></div>
         <div class="stat-content-lg">
           <h3>{{ statsData?.clickRate }}</h3>
           <p>点击率</p>
         </div>
-      </div>
+      </div> -->
       <div class="stat-card-lg">
         <div class="stat-icon-lg orange"><i class="fas fa-reply"></i></div>
         <div class="stat-content-lg">