Просмотр исходного кода

Merge branch 'master' of https://gogs.storlead.com/storlead/storlead-sasa-platform

183207892172 3 недель назад
Родитель
Сommit
ad2da8c4e3
20 измененных файлов с 827 добавлено и 74 удалено
  1. 25 12
      Jenkinsfile
  2. 24 2
      java/storlead-api/src/main/resources/application-test.yml
  3. 273 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingCampaignSopDetailController.java
  4. 8 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/CustomerAnalysisResultDTO.java
  5. 57 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignCustomerDTO.java
  6. 170 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignSopDetailDTO.java
  7. 8 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/CustomerAnalysisResultEntity.java
  8. 11 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/mapper/CustomerBaseEntityMapper.java
  9. 8 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/CustomerBaseEntityService.java
  10. 15 1
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerBaseEntityServiceImpl.java
  11. 44 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketingCampaignCustomerVO.java
  12. 37 0
      java/storlead-sasa/storlead-trade/src/main/resources/mapper/CustomerBaseEntityMapper.xml
  13. 1 1
      ui/smarttrade-platform/.env.production
  14. 1 1
      ui/smarttrade-platform/.env.test
  15. 12 1
      ui/smarttrade-platform/src/api/knowledge/index.js
  16. 1 0
      ui/smarttrade-platform/src/api/marketing/index.js
  17. 1 1
      ui/smarttrade-platform/src/utils/request.js
  18. 2 2
      ui/smarttrade-platform/src/views/CustomersView.vue
  19. 114 46
      ui/smarttrade-platform/src/views/KnowledgeView.vue
  20. 15 7
      ui/smarttrade-platform/src/views/MarketingView.vue

+ 25 - 12
Jenkinsfile

@@ -111,13 +111,13 @@ pipeline {
                                 if (params.profile == "test") {
                                     SWARM_INIT_REPLICAS_NUM = 1
                                     SERVER_HOST = "test1.storlead.com"
-                                    API_PORT = 10060
-                                    API_REMOTE_DEBUG_PORT = 10061
+                                    API_PORT = 10070
+                                    API_REMOTE_DEBUG_PORT = 10071
                                     SERVER_HOST_NAME =  "test1"
                                 } else if (params.profile == "prod") {
                                     SERVER_HOST = "110.41.82.21"
-                                    API_PORT = 10060
-                                    API_REMOTE_DEBUG_PORT = 10061
+                                    API_PORT = 10070
+                                    API_REMOTE_DEBUG_PORT = 10071
                                     SERVER_HOST_NAME =  "node1"
                                 }
                             }
@@ -208,7 +208,8 @@ pipeline {
             }
             agent {
                 docker {
-                    image 'maven:3-amazoncorretto-11'
+                    // 与 Temurin 17 一致(含 Maven);若本地无此镜像会自动拉取
+                    image 'maven:3-eclipse-temurin-17'
                     //让docker 使用 host 宿主机的 m2仓库 使用root用户来运行 以让指定的~/.m2/config/setting.xml 阿里加速下载maven 依赖生效
                     args '-v /app:/app:z -v $HOME/.m2:/root/.m2:z -u root'
                     reuseNode true
@@ -216,7 +217,8 @@ pipeline {
             }
             steps {
                 echo "编译 API 代码"
-                sh "mvn clean package -U -am -pl ${params.module} -P${params.profile} -Dmaven.test.skip=true"
+                // 根 reactor 模块路径为 java/storlead-api,-pl 用 :artifactId 按构件名选择
+                sh "mvn clean package -U -am -pl :${params.module} -P${params.profile} -Dmaven.test.skip=true"
             }
         }
 
@@ -230,14 +232,15 @@ pipeline {
                 script {
                     docker.withRegistry(DOCKER_LOGIN_REGISTRY, DOCKER_REGISTRY) {
 
-                        BASE_IMAGE = 'registry.cn-shenzhen.aliyuncs.com/storlead/openjdk:11'
-                        sh "docker pull ${BASE_IMAGE}"
+                        // 使用 Jenkins 节点已有的 eclipse-temurin:17
+                        BASE_IMAGE = 'eclipse-temurin:17'
+                        sh "docker pull ${BASE_IMAGE} || true"
 
                         sh """
                           echo '
                           FROM ${BASE_IMAGE}
 
-                          COPY ${params.module}/target/${params.module}.jar app.jar
+                          COPY java/${params.module}/target/${params.module}-${VERSION}.jar app.jar
                           EXPOSE ${API_PORT}
                           EXPOSE ${API_REMOTE_DEBUG_PORT}
                           HEALTHCHECK --interval=1m --timeout=10s CMD curl -f http://localhost:${API_PORT}/${API_NAMESPACE_RESTFUL}/actuator
@@ -288,7 +291,7 @@ pipeline {
                             sshCommand remote: remote, command: "docker pull ${DOCKER_IMG_NAME}"
                         }
                         sh """
-                          echo '
+                        echo '
   version: "3.7"
   services:
     ${params.module}-${params.profile}:
@@ -298,9 +301,17 @@ pipeline {
         placement:
           constraints:  
             - 'node.hostname == ${SERVER_HOST_NAME}'
+
+        resources:
+          limits:
+            memory: 2G
+          reservations:
+            memory: 512M
+
         update_config:
           parallelism: 1
           delay: 1m
+
         restart_policy:
           condition: on-failure
           # 最多连续重试3次如果仍然失败则放弃重试
@@ -314,6 +325,7 @@ pipeline {
       environment:
         - "TZ=Asia/Shanghai"
         - "SPRING_PROFILES_ACTIVE=${params.profile}"
+        - "JAVA_OPTS=-Xms512m -Xmx1024m"
       volumes:
         - /app:/app
         - /mnt/vdb/storlead:/mnt/vdb/storlead
@@ -321,7 +333,7 @@ pipeline {
         - "${API_PORT}:${API_PORT}"
         - "${API_REMOTE_DEBUG_PORT}:${API_REMOTE_DEBUG_PORT}"
       networks:
-        - vonedao_net
+        - ${ARTIFACTID}
       extra_hosts:
         - "www.storlead.com:172.18.194.161"
         - "node1:110.41.82.21"
@@ -330,7 +342,8 @@ pipeline {
         - "test1.storlead.com:172.18.194.168"
         - "test2.storlead.com:172.18.194.163"
   networks:
-    vonedao_net:
+    ${ARTIFACTID}:
+       driver: overlay
        external: true
                           ' > ${params.module}-${params.profile}-stack.yml
                          """

+ 24 - 2
java/storlead-api/src/main/resources/application-test.yml

@@ -1,5 +1,5 @@
 server:
-  port: 10020
+  port: 10070
   tomcat:
     max-swallow-size: -1
     basedir: /app/sp/smarttrade/temp/${spring.profiles.active}
@@ -89,7 +89,7 @@ spring:
         connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
       datasource:
         master:
-          url: jdbc:mysql://mysql.test.storlead.com:39091/sp_smart_trade_test?useSSL=false&useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true
+          url: jdbc:mysql://mysql.test.storlead.com:39091/sp_saas_platform_test?useSSL=false&useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true
           username: root
           password: rCgRgLjH99Xvg5BN
           driver-class-name: com.mysql.jdbc.Driver
@@ -113,6 +113,16 @@ spring:
           url: jdbc:mysql://mysql.test.storlead.com:39091/storlead_test?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&noDatetimeStringSync=true&serverTimezone=Asia/Shanghai
           username: root
           password: rCgRgLjH99Xvg5BN
+        otr:
+          driver-class-name: com.mysql.jdbc.Driver
+          url: jdbc:mysql://mysql.test.storlead.com:39091/storlead_test?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&noDatetimeStringSync=true&serverTimezone=Asia/Shanghai
+          username: root
+          password: rCgRgLjH99Xvg5BN
+        oa:
+          driver-class-name: com.mysql.jdbc.Driver
+          url: jdbc:mysql://139.159.206.64:65369/storlead_ecology_prod?useSSL=false&useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true
+          username: storlead_ecology
+          password: 3raNoDvbo7jqbwtedQGQ
 
 #mybatis plus 设置
 mybatis-plus:
@@ -162,6 +172,8 @@ logging:
               Jackson2ObjectMapperBuilder: off # 禁止okhttp4 打印警告日志 For Jackson Kotlin classes support please add "com.fasterxml.jackson.module:jackson-module-kotlin" to the classpath
     com:
       storlead: debug
+
+environment: test
 dify:
   base-url: http://47.112.196.2:11434/v1/
   dataset-api-key: dataset-qfhXMNcjsRSwN5CK6aoWx3hl
@@ -172,3 +184,13 @@ dify:
     mail: app-4-qfhXMNcjsRSwN5CK6aoWx3hl #邮件
 
 domainname: https://test1.storlead.com
+
+
+storlead:
+  es:
+    enabled: false
+    host: 39.108.252.62
+    port: 9200
+    scheme: http
+    username: elastic
+    password: storlead123456

+ 273 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingCampaignSopDetailController.java

@@ -4,20 +4,32 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.storlead.framework.common.result.Result;
+import com.storlead.mail.pojo.entity.EmailsEntity;
+import com.storlead.trade.dto.MarketingCampaignCustomerDTO;
 import com.storlead.trade.dto.MarketingCampaignSopDetailDTO;
+import com.storlead.trade.vo.MarketingCampaignCustomerVO;
+import com.storlead.trade.entity.CustomerBaseEntity;
 import com.storlead.trade.entity.MarketingCampaignSopDetailEntity;
+import com.storlead.trade.service.CustomerBaseEntityService;
 import com.storlead.trade.service.MarketingCampaignSopDetailService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.BeanUtils;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * 营销活动SOP明细 Controller
@@ -30,6 +42,20 @@ public class MarketingCampaignSopDetailController {
     @Resource
     private MarketingCampaignSopDetailService marketingCampaignSopDetailService;
 
+    @Resource
+    private CustomerBaseEntityService customerBaseEntityService;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    /** 智能调度类型常量 */
+    private static final String SCHEDULE_TYPE_NOW = "now";
+    private static final String SCHEDULE_TYPE_SCHEDULED = "scheduled";
+    private static final String SCHEDULE_TYPE_AI = "ai";
+
+    /** 邮件状态:-1待发送 */
+    private static final int EMAIL_STATUS_PENDING = -1;
+
     /**
      * 根据id查询详情
      */
@@ -96,6 +122,227 @@ public class MarketingCampaignSopDetailController {
         return Result.result(entity);
     }
 
+
+    @PostMapping("/add-confirm")
+    @ApiOperation(value = "确认创建/编辑营销活动", notes = "根据智能调度类型生成待发送邮件")
+    @Transactional(rollbackFor = Throwable.class)
+    public Result<?> addConfirm(@RequestBody MarketingCampaignSopDetailDTO dto) {
+        if (ObjectUtils.isEmpty(dto.getMarketingCampaignId())) {
+            return Result.error("营销活动id不能为空");
+        }
+
+        String scheduleType = dto.getSmartSchedulingType();
+        if (scheduleType == null || scheduleType.isEmpty()) {
+            return Result.error("智能调度类型不能为空");
+        }
+
+        // 查询该营销活动下的所有SOP明细(按步骤排序)
+        LambdaQueryWrapper<MarketingCampaignSopDetailEntity> sopWrapper = new LambdaQueryWrapper<>();
+        sopWrapper.eq(MarketingCampaignSopDetailEntity::getMarketingCampaignId, dto.getMarketingCampaignId());
+        sopWrapper.orderByAsc(MarketingCampaignSopDetailEntity::getStep);
+        List<MarketingCampaignSopDetailEntity> sopDetails = marketingCampaignSopDetailService.list(sopWrapper);
+        if (CollectionUtils.isEmpty(sopDetails)) {
+            return Result.error("该营销活动未关联SOP明细");
+        }
+
+        // 查询该营销活动下的所有客户(customer_base表)
+        List<CustomerBaseEntity> customers = customerBaseEntityService.lambdaQuery()
+                .eq(CustomerBaseEntity::getMarketingCampaignId, dto.getMarketingCampaignId())
+                .list();
+        if (CollectionUtils.isEmpty(customers)) {
+            return Result.error("该营销活动未关联客户");
+        }
+
+        // 计算每一步的发送时间
+        LocalDateTime baseTime = resolveBaseSendTime(scheduleType, dto);
+
+        // 生成邮件
+        int generated = 0;
+        for (CustomerBaseEntity customer : customers) {
+            generated += generateEmailsForCustomer(customer, sopDetails, dto, baseTime, scheduleType);
+        }
+
+        java.util.Map<String, Object> result = new java.util.HashMap<>();
+        result.put("scheduleType", scheduleType);
+        result.put("customerCount", customers.size());
+        result.put("sopStepCount", sopDetails.size());
+        result.put("emailsGenerated", generated);
+        return Result.result(result);
+    }
+
+    /**
+     * 计算基础发送时间
+     */
+    private LocalDateTime resolveBaseSendTime(String scheduleType, MarketingCampaignSopDetailDTO dto) {
+        if (SCHEDULE_TYPE_NOW.equalsIgnoreCase(scheduleType)) {
+            // 立即发送
+            return LocalDateTime.now();
+        }
+        if (SCHEDULE_TYPE_SCHEDULED.equalsIgnoreCase(scheduleType)) {
+            // 定时发送:使用指定日期 + 时间
+            LocalDate date = dto.getSmartSchedulingStartDate() != null
+                    ? dto.getSmartSchedulingStartDate() : LocalDate.now();
+            String timeStr = dto.getSmartSchedulingStartTime();
+            LocalTime time = (timeStr != null && !timeStr.isEmpty())
+                    ? LocalTime.parse(timeStr, DateTimeFormatter.ofPattern("HH:mm"))
+                    : LocalTime.of(9, 0);
+            return LocalDateTime.of(date, time);
+        }
+        // AI最优时间:当前日期 + 默认上午9点
+        return LocalDateTime.of(LocalDate.now(), LocalTime.of(9, 0));
+    }
+
+    /**
+     * 为单个客户生成所有SOP步骤的邮件
+     */
+    private int generateEmailsForCustomer(CustomerBaseEntity customer,
+                                          List<MarketingCampaignSopDetailEntity> sopDetails,
+                                          MarketingCampaignSopDetailDTO dto,
+                                          LocalDateTime baseTime,
+                                          String scheduleType) {
+        // 对每个客户去重步骤
+        Set<Integer> stepSet = new HashSet<>();
+        int count = 0;
+        for (MarketingCampaignSopDetailEntity sop : sopDetails) {
+            Integer step = sop.getStep() == null ? 0 : sop.getStep();
+            if (!stepSet.add(step)) {
+                continue;
+            }
+            // 计算发送时间:baseTime + 之前所有步骤累加间隔天数
+            LocalDateTime sendTime = baseTime;
+            for (MarketingCampaignSopDetailEntity prev : sopDetails) {
+                if (prev.getStep() == null || sop.getStep() == null) continue;
+                if (prev.getStep() >= sop.getStep()) break;
+                sendTime = addInterval(sendTime, prev.getDays());
+            }
+            // 立即发送类型则让 sent_date 为 null 由调度发送;delay_send_time 仅对非立即模式生效
+            EmailsEntity email = buildEmailEntity(customer, sop, dto, sendTime, scheduleType);
+            insertEmail(email);
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * 累加间隔天数
+     */
+    private LocalDateTime addInterval(LocalDateTime base, String days) {
+        if (days == null || days.isEmpty()) {
+            return base;
+        }
+        try {
+            int d = Integer.parseInt(days.trim());
+            return base.plusDays(d);
+        } catch (NumberFormatException e) {
+            return base;
+        }
+    }
+
+    /**
+     * 构造 EmailsEntity(不依赖 mail-biz)
+     */
+    private EmailsEntity buildEmailEntity(CustomerBaseEntity customer,
+                                          MarketingCampaignSopDetailEntity sop,
+                                          MarketingCampaignSopDetailDTO dto,
+                                          LocalDateTime sendTime,
+                                          String scheduleType) {
+        EmailsEntity email = new EmailsEntity();
+        email.setSubject(dto.getEmailSubject() != null ? dto.getEmailSubject() : sop.getName());
+        email.setFrom(dto.getEmailAddress());
+        email.setFromName(dto.getDescription());
+        email.setContent(sop.getEmailContent() != null ? sop.getEmailContent() : "");
+        email.setStatus(EMAIL_STATUS_PENDING);
+        email.setInOutMark(2); // 2 = 出
+        email.setIsRead(0);
+        email.setIsStar(0);
+        email.setIsTrash(0);
+        email.setIsReceipt(0);
+        email.setIsTrack(dto.getTrackConfigOpen() != null ? dto.getTrackConfigOpen() : 0);
+        email.setIsOnlyHead(0);
+        email.setSourceType(0);
+        // 收件人:用 customerAnalysisId 占位(实际邮箱需关联客户表)
+        email.setCustomerId(customer.getCustomerAnalysisId());
+        // 触达渠道作为 folder
+        email.setFolder(sop.getType() != null ? sop.getType() : "email");
+        // 定时发送
+        if (!SCHEDULE_TYPE_NOW.equalsIgnoreCase(scheduleType)) {
+            email.setDelaySendTime(sendTime);
+            email.setSentDate(null);
+        } else {
+            email.setDelaySendTime(null);
+            // 立即发送:sent_date 是 java.util.Date 类型,需要从 LocalDateTime 转换
+            email.setSentDate(java.util.Date.from(sendTime.atZone(java.time.ZoneId.systemDefault()).toInstant()));
+        }
+        // 关联营销活动 id 和 sop 步骤信息到 remark
+        email.setRemark("marketingCampaignId=" + dto.getMarketingCampaignId()
+                + ",sopDetailId=" + sop.getSopDetailId()
+                + ",step=" + sop.getStep()
+                + ",scheduleType=" + scheduleType);
+        return email;
+    }
+
+    /**
+     * 通过 JDBC 插入 emails 表,避免 trade 模块依赖 mail-biz
+     * 采用动态拼列方式,仅写入 EmailsEntity 中非空的字段,最大程度兼容表结构变更
+     */
+    private void insertEmail(EmailsEntity email) {
+        java.util.LinkedHashMap<String, Object> fieldMap = new java.util.LinkedHashMap<>();
+        putIfPresent(fieldMap, "subject", email.getSubject());
+        putIfPresent(fieldMap, "recipient", email.getRecipient());
+        putIfPresent(fieldMap, "recipient_name", email.getRecipientName());
+        putIfPresent(fieldMap, "recipient_cc", email.getRecipientCc());
+        putIfPresent(fieldMap, "recipient_cc_name", email.getRecipientCcName());
+        putIfPresent(fieldMap, "recipient_bcc", email.getRecipientBcc());
+        putIfPresent(fieldMap, "recipient_bcc_name", email.getRecipientBccName());
+        putIfPresent(fieldMap, "`from`", email.getFrom());
+        putIfPresent(fieldMap, "from_name", email.getFromName());
+        putIfPresent(fieldMap, "content", email.getContent());
+        putIfPresent(fieldMap, "folder", email.getFolder());
+        putIfPresent(fieldMap, "sent_date", email.getSentDate() != null
+                ? new java.sql.Timestamp(email.getSentDate().getTime()) : null);
+        putIfPresent(fieldMap, "delay_send_time", email.getDelaySendTime());
+        putIfPresent(fieldMap, "status", email.getStatus());
+        putIfPresent(fieldMap, "remark", email.getRemark());
+        putIfPresent(fieldMap, "in_out_mark", email.getInOutMark());
+        putIfPresent(fieldMap, "is_read", email.getIsRead());
+        putIfPresent(fieldMap, "is_track", email.getIsTrack());
+        putIfPresent(fieldMap, "is_star", email.getIsStar());
+        putIfPresent(fieldMap, "is_trash", email.getIsTrash());
+
+        // 必填字段默认值
+        fieldMap.putIfAbsent("status", -1);
+        fieldMap.putIfAbsent("is_read", 0);
+        fieldMap.putIfAbsent("is_trash", 0);
+        fieldMap.putIfAbsent("is_star", 0);
+        fieldMap.putIfAbsent("in_out_mark", 2);
+        fieldMap.putIfAbsent("is_delete", 0);
+        fieldMap.putIfAbsent("create_time", new java.sql.Timestamp(System.currentTimeMillis()));
+        fieldMap.putIfAbsent("update_time", new java.sql.Timestamp(System.currentTimeMillis()));
+
+        StringBuilder sql = new StringBuilder("INSERT INTO emails (");
+        StringBuilder placeholders = new StringBuilder();
+        List<Object> args = new ArrayList<>();
+        boolean first = true;
+        for (java.util.Map.Entry<String, Object> e : fieldMap.entrySet()) {
+            if (!first) {
+                sql.append(", ");
+                placeholders.append(", ");
+            }
+            sql.append(e.getKey());
+            placeholders.append("?");
+            args.add(e.getValue());
+            first = false;
+        }
+        sql.append(") VALUES (").append(placeholders).append(")");
+        jdbcTemplate.update(sql.toString(), args.toArray());
+    }
+
+    private void putIfPresent(java.util.Map<String, Object> map, String key, Object value) {
+        if (value != null) {
+            map.put(key, value);
+        }
+    }
+
     /**
      * 批量新增
      */
@@ -172,4 +419,30 @@ public class MarketingCampaignSopDetailController {
         List<MarketingCampaignSopDetailEntity> list = marketingCampaignSopDetailService.list(wrapper);
         return Result.result(list);
     }
+
+    /**
+     * 查询营销活动关联的客户列表(打开邮件的客户)
+     */
+    @PostMapping("/customers")
+    @ApiOperation(value = "查询营销活动关联的客户列表", notes = "支持按状态筛选:全部/高价值/已保护/营销暂停")
+    public Result<Object> getCampaignCustomers(@RequestBody MarketingCampaignCustomerDTO dto) {
+        if (ObjectUtils.isEmpty(dto.getMarketingCampaignId())) {
+            return Result.error("营销活动id不能为空");
+        }
+
+        IPage<MarketingCampaignCustomerVO> page = customerBaseEntityService.getCampaignCustomers(dto);
+
+        // 处理状态标签
+        for (MarketingCampaignCustomerVO vo : page.getRecords()) {
+            if (vo.getHasProtect() != null && vo.getHasProtect() == 1) {
+                vo.setStatusTag("已保护");
+            } else if (vo.getHasSuspend() != null && vo.getHasSuspend() == 1) {
+                vo.setStatusTag("营销暂停");
+            } else {
+                vo.setStatusTag("全部");
+            }
+        }
+
+        return Result.result(page);
+    }
 }

+ 8 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/CustomerAnalysisResultDTO.java

@@ -105,4 +105,12 @@ public class CustomerAnalysisResultDTO extends Page {
 
     @ApiModelProperty(value = "idList")
     private List<Long> idList;
+
+    @ApiModelProperty(value = "是否是保护客户(0否,1是)")
+    @TableField("has_protect")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否是暂停客户(0否,1是)")
+    @TableField("has_suspend")
+    private Integer hasSuspend;
 }

+ 57 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignCustomerDTO.java

@@ -0,0 +1,57 @@
+package com.storlead.trade.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 营销活动关联客户信息 DTO
+ */
+@Data
+@ApiModel(value = "MarketingCampaignCustomerDTO", description = "营销活动关联客户信息")
+public class MarketingCampaignCustomerDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "营销活动ID")
+    private Long marketingCampaignId;
+
+    @ApiModelProperty(value = "客户ID")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "客户邮箱")
+    private String email;
+
+    @ApiModelProperty(value = "保护状态:0未保护,1已保护")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "暂停状态:0未暂停,1已暂停")
+    private Integer hasSuspend;
+
+    @ApiModelProperty(value = "客户等级")
+    private String customerLevel;
+
+    @ApiModelProperty(value = "客户状态标签:全部/高价值/已保护/营销暂停")
+    private String statusTag;
+
+    @ApiModelProperty(value = "邮件打开时间")
+    private String openTime;
+
+    @ApiModelProperty(value = "点击次数")
+    private Integer clickCount;
+
+    @ApiModelProperty(value = "页码")
+    private Integer pageIndex = 1;
+
+    @ApiModelProperty(value = "每页数量")
+    private Integer pageSize = 10;
+
+    @ApiModelProperty(value = "状态筛选:all全部/highValue高价值/protected已保护/suspended营销暂停")
+    private String filterStatus;
+}

+ 170 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketingCampaignSopDetailDTO.java

@@ -1,10 +1,17 @@
 package com.storlead.trade.dto;
 
+import com.alibaba.fastjson.annotation.JSONField;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
 
 import java.io.Serializable;
+import java.time.LocalDate;
 import java.util.List;
 
 /**
@@ -48,4 +55,167 @@ public class MarketingCampaignSopDetailDTO implements Serializable {
 
     @ApiModelProperty(value = "每页数量", required = true)
     private Integer pageSize = 10;
+
+
+    @ApiModelProperty(value = "优先级(1紧急、2高优先、3中等、4低优先)")
+    @TableField("priority")
+    private Integer priority;
+
+    @ApiModelProperty(value = "开始日期")
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern="yyyy-MM-dd")
+    @JSONField(format ="yyyy-MM-dd")
+    @TableField("begin_data")
+    private LocalDate beginData;
+
+    @ApiModelProperty(value = "结束日期")
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern="yyyy-MM-dd")
+    @JSONField(format ="yyyy-MM-dd")
+    @TableField("end_date")
+    private LocalDate endDate;
+
+    @ApiModelProperty(value = "负责人id")
+    @TableField("supervisor")
+    private Long supervisor;
+
+    @ApiModelProperty(value = "关联商机")
+    @TableField("link_to_opportunity")
+    private Long linkToOpportunity;
+
+    @ApiModelProperty(value = "活动描述")
+    @TableField("description")
+    private String description;
+
+    @ApiModelProperty(value = "发送量")
+    @TableField("send_volume")
+    private Integer sendVolume;
+
+    @ApiModelProperty(value = "打开率")
+    @TableField("open_rate")
+    private Double openRate;
+
+    @ApiModelProperty(value = "回复率")
+    @TableField("reply_rate")
+    private Double replyRate;
+
+    @ApiModelProperty(value = "商机数")
+    @TableField("opportunity_count")
+    private Integer opportunityCount;
+
+    @ApiModelProperty(value = "渠道配置")
+    @TableField("channel_configuration")
+    private Integer channelConfiguration;
+
+    @ApiModelProperty(value = "发送邮箱")
+    @TableField("email_address")
+    private String emailAddress;
+
+    @ApiModelProperty(value = "发送时间")
+    @TableField("email_sent_time")
+    private String emailSentTime;
+
+    @ApiModelProperty(value = "邮件模板")
+    @TableField("email_template")
+    private String emailTemplate;
+
+    @ApiModelProperty(value = "每批发送量")
+    @TableField("email_batch_size")
+    private Integer emailBatchSize;
+
+    @ApiModelProperty(value = "邮件主题")
+    @TableField("email_subject")
+    private String emailSubject;
+
+    @ApiModelProperty(value = "是否追踪邮件打开(1是0否)")
+    @TableField("track_config_open")
+    private Integer trackConfigOpen;
+
+    @ApiModelProperty(value = "是否追踪链接点击(1是0否)")
+    @TableField("track_config_click")
+    private Integer trackConfigClick;
+
+    @ApiModelProperty(value = "是否追踪回复(1是0否)")
+    @TableField("track_config_reply")
+    private Integer trackConfigReply;
+
+    @ApiModelProperty(value = "SOP序列id")
+    @TableField("sop_id")
+    private Integer sopId;
+
+    @ApiModelProperty(value = "sop详情id(逗号分隔)")
+    @TableField("sop_detail_ids")
+    private String sopDetailIds;
+
+    @ApiModelProperty(value = "未打开邮件跟进规则(1是0否)")
+    @TableField("follow_up_rules_not_opened")
+    private Integer followUpRulesNotOpened;
+
+    @ApiModelProperty(value = "未打开邮件跟进天数")
+    @TableField("follow_up_rules_not_opened_days")
+    private String followUpRulesNotOpenedDays;
+
+    @ApiModelProperty(value = "未回复邮件跟进规则(1是0否)")
+    @TableField("follow_up_rules_not_replied")
+    private Integer followUpRulesNotReplied;
+
+    @ApiModelProperty(value = "未回复邮件跟进天数")
+    @TableField("follow_up_rules_not_replied_days")
+    private String followUpRulesNotRepliedDays;
+
+    @ApiModelProperty(value = "未点击链接跟进规则(1是0否)")
+    @TableField("follow_up_rules_link_not_clicked")
+    private Integer followUpRulesLinkNotClicked;
+
+    @ApiModelProperty(value = "未点击链接跟进天数")
+    @TableField("follow_up_rules_link_not_clicked_days")
+    private String followUpRulesLinkNotClickedDays;
+
+    @ApiModelProperty(value = "客户回复后自动停止(1是0否)")
+    @TableField("follow_up_rules_replied")
+    private Integer followUpRulesReplied;
+
+    @ApiModelProperty(value = "客户预约后停止(1是0否)")
+    @TableField("follow_up_rules_appointment_booked")
+    private Integer followUpRulesAppointmentBooked;
+
+    @ApiModelProperty(value = "A/B测试(1是0否)")
+    @TableField("a_b_testing")
+    private Integer aBTesting;
+
+    @ApiModelProperty(value = "智能调度类型")
+    @TableField("smart_scheduling_type")
+    private String smartSchedulingType;
+
+    @ApiModelProperty(value = "智能调度开始日期")
+    @TableField("smart_scheduling_start_date")
+    private LocalDate smartSchedulingStartDate;
+
+    @ApiModelProperty(value = "智能调度开始时间")
+    @TableField("smart_scheduling_start_time")
+    private String smartSchedulingStartTime;
+
+    @ApiModelProperty(value = "是否已确认内容合规")
+    @TableField("has_content_compliant")
+    private Integer hasContentCompliant;
+
+    @ApiModelProperty(value = "是否已确认发送时间合理")
+    @TableField("has_schedule_approved")
+    private Integer hasScheduleApproved;
+
+    @ApiModelProperty(value = "是否符合GDPR")
+    @TableField("has_gdpr_compliant")
+    private Integer hasGdprCompliant;
+
+    @ApiModelProperty(value = "是否包含退订链接")
+    @TableField("has_unsubscribe_link")
+    private Integer hasUnsubscribeLink;
+
+    @ApiModelProperty(value = "进度百分比")
+    @TableField("progress_percent")
+    private Double progressPercent;
+
+    @ApiModelProperty(value = "状态 0未开始 1运行中 2暂停 3完成")
+    @TableField("status")
+    private Integer status;
 }

+ 8 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/CustomerAnalysisResultEntity.java

@@ -1,6 +1,7 @@
 package com.storlead.trade.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.storlead.framework.mybatis.entity.TenantBaseField;
@@ -59,4 +60,11 @@ public class CustomerAnalysisResultEntity extends TenantBaseField {
     private Date nextFollowUpAt;
     @ApiModelProperty(value = "分析时间")
     private Date analysisTime;
+
+    @ApiModelProperty(value = "是否是保护客户(0否,1是)")
+    @TableField("has_protect")
+    private Integer hasProtect;
+    @ApiModelProperty(value = "是否是暂停客户(0否,1是)")
+    @TableField("has_suspend")
+    private Integer hasSuspend;
 }

+ 11 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/mapper/CustomerBaseEntityMapper.java

@@ -1,9 +1,20 @@
 package com.storlead.trade.mapper;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.storlead.trade.dto.MarketingCampaignCustomerDTO;
 import com.storlead.trade.entity.CustomerBaseEntity;
 import com.storlead.framework.mybatis.mapper.MyBaseMapper;
+import com.storlead.trade.vo.MarketingCampaignCustomerVO;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 @Mapper
 public interface CustomerBaseEntityMapper extends MyBaseMapper<CustomerBaseEntity> {
+
+    /**
+     * 查询营销活动关联的客户列表
+     */
+    IPage<MarketingCampaignCustomerVO> selectCampaignCustomers(Page<MarketingCampaignCustomerVO> page,
+                                                                @Param("dto") MarketingCampaignCustomerDTO dto);
 }

+ 8 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/CustomerBaseEntityService.java

@@ -1,7 +1,15 @@
 package com.storlead.trade.service;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.trade.dto.MarketingCampaignCustomerDTO;
 import com.storlead.trade.entity.CustomerBaseEntity;
 import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.trade.vo.MarketingCampaignCustomerVO;
 
 public interface CustomerBaseEntityService extends MyBaseService<CustomerBaseEntity> {
+
+    /**
+     * 查询营销活动关联的客户列表
+     */
+    IPage<MarketingCampaignCustomerVO> getCampaignCustomers(MarketingCampaignCustomerDTO dto);
 }

+ 15 - 1
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerBaseEntityServiceImpl.java

@@ -1,14 +1,28 @@
 package com.storlead.trade.service.impl;
 
-
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.storlead.trade.dto.MarketingCampaignCustomerDTO;
 import com.storlead.trade.entity.CustomerBaseEntity;
 import com.storlead.trade.mapper.CustomerBaseEntityMapper;
 import com.storlead.trade.service.CustomerBaseEntityService;
+import com.storlead.trade.vo.MarketingCampaignCustomerVO;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import org.springframework.stereotype.Service;
 
+import javax.annotation.Resource;
+
 @Service
 public class CustomerBaseEntityServiceImpl
         extends MyBaseServiceImpl<CustomerBaseEntityMapper, CustomerBaseEntity>
         implements CustomerBaseEntityService {
+
+    @Resource
+    private CustomerBaseEntityMapper customerBaseEntityMapper;
+
+    @Override
+    public IPage<MarketingCampaignCustomerVO> getCampaignCustomers(MarketingCampaignCustomerDTO dto) {
+        Page<MarketingCampaignCustomerVO> page = new Page<>(dto.getPageIndex(), dto.getPageSize());
+        return customerBaseEntityMapper.selectCampaignCustomers(page, dto);
+    }
 }

+ 44 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketingCampaignCustomerVO.java

@@ -0,0 +1,44 @@
+package com.storlead.trade.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 营销活动关联客户信息 VO
+ */
+@Data
+@ApiModel(value = "MarketingCampaignCustomerVO", description = "营销活动关联客户信息")
+public class MarketingCampaignCustomerVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "客户ID")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "客户邮箱")
+    private String email;
+
+    @ApiModelProperty(value = "保护状态:0未保护,1已保护")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "暂停状态:0未暂停,1已暂停")
+    private Integer hasSuspend;
+
+    @ApiModelProperty(value = "客户等级")
+    private String customerLevel;
+
+    @ApiModelProperty(value = "客户状态标签:全部/高价值/已保护/营销暂停")
+    private String statusTag;
+
+    @ApiModelProperty(value = "邮件打开时间")
+    private String openTime;
+
+    @ApiModelProperty(value = "点击次数")
+    private Integer clickCount;
+}

+ 37 - 0
java/storlead-sasa/storlead-trade/src/main/resources/mapper/CustomerBaseEntityMapper.xml

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.storlead.trade.mapper.CustomerBaseEntityMapper">
+
+    <!-- 查询营销活动关联的客户列表 -->
+    <select id="selectCampaignCustomers" resultType="com.storlead.trade.vo.MarketingCampaignCustomerVO">
+        SELECT
+            c.id AS customerId,
+            c.customer_name AS customerName,
+            l.email AS email,
+            COALESCE(car.has_protect, 0) AS hasProtect,
+            COALESCE(car.has_suspend, 0) AS hasSuspend,
+            car.customer_level AS customerLevel,
+            '' AS openTime,
+            0 AS clickCount
+        FROM customer_base cb
+        LEFT JOIN customer c ON cb.customer_analysis_id = c.id
+        LEFT JOIN liaison l ON c.id = l.customer_id AND l.has_primary_lialison = 1
+        LEFT JOIN customer_analysis_result car ON c.id = car.customer_id
+        WHERE cb.marketing_campaign_id = #{dto.marketingCampaignId}
+          AND cb.is_delete = 0
+          AND c.is_delete = 0
+        <choose>
+            <when test="dto.filterStatus == 'protected'">
+                AND car.has_protect = 1
+            </when>
+            <when test="dto.filterStatus == 'suspended'">
+                AND car.has_suspend = 1
+            </when>
+            <when test="dto.filterStatus == 'highValue'">
+                AND car.customer_level IN ('A', 'B', '高价值', 'high')
+            </when>
+        </choose>
+        ORDER BY cb.create_time DESC
+    </select>
+
+</mapper>

+ 1 - 1
ui/smarttrade-platform/.env.production

@@ -1,4 +1,4 @@
 # 生产环境后台接口地址
-VITE_API_BASE_URL=/api
+VITE_API_BASE_URL=/
 
 VITE_API_APP_ID=7

+ 1 - 1
ui/smarttrade-platform/.env.test

@@ -1,4 +1,4 @@
 # 测试环境后台接口地址
-VITE_API_BASE_URL=/api
+VITE_API_BASE_URL=/
 
 VITE_API_APP_ID=7

+ 12 - 1
ui/smarttrade-platform/src/api/knowledge/index.js

@@ -29,6 +29,11 @@ export const getAllDocumnets = (params = {}) => {
     return get('/router/rest/knowledge/document/getAllDocumnets', query)
 }
 
+export const getDocumentDetail = (params = {}) => {
+    const { dataset_id,document_id, ...query } = params
+    return get(`/router/rest/knowledge/document/${dataset_id}/documents/${document_id}`, params)
+}
+
 export const delDocuments = (params = {}) => {
     const { dataset_id, document_id, ...query } = params
     return remove(`/router/rest/knowledge/document/${dataset_id}/documents/${document_id}`, params)
@@ -47,4 +52,10 @@ export const delChunk = (params = {}) => {
 export const editChunk = (data = {}) => {
     const { dataset_id,document_id,segment_id, ...query } = data
     return post(`/router/rest/knowledge/chunk/${dataset_id}/${document_id}/${segment_id}`, data)
-}
+}
+
+export const blocking = (data = {}) => post('/router/rest/chat/blocking', data,{
+    headers: {
+        'Authorization': 'Bearer app-KpoSEqve0ybB2Ej5PIJiBu2k'
+    }
+})

+ 1 - 0
ui/smarttrade-platform/src/api/marketing/index.js

@@ -7,3 +7,4 @@ export const editSop = (id, data = {}) => put(`/router/rest/api/sop/${id}`, data
 export const getSopById = (id) => get(`/router/rest/api/sop/${id}`)
 export const addMarketing = (data = {}) => post('/router/rest/marketing/add', data)
 export const editMarketing = (data = {}) => post('/router/rest/marketing/edit', data)
+export const marketingAnalysis = () => get('/router/rest/marketing/dashboard/stats')

+ 1 - 1
ui/smarttrade-platform/src/utils/request.js

@@ -31,7 +31,7 @@ const service = axios.create({
   baseURL: BASE_URL,
   timeout: TIMEOUT,
   headers: {
-    'Content-Type': 'application/json;charset=utf-8'
+    'Content-Type': 'application/json;charset=utf-8',
   }
 })
 

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

@@ -41,13 +41,13 @@
       <div style="display: flex; gap: 8px;">
         <el-cascader class="form-select" style="width: auto; padding: 0;" placeholder="区域名称" :options="region"
           v-model="regionValue" clearable filterable @change="getRegion" />
-        <select v-model="industryFilter" class="form-select" style="width: auto; padding: 8px 16px;">
+        <!-- <select v-model="industryFilter" class="form-select" style="width: auto; padding: 8px 16px;">
           <option value="">全部行业</option>
           <option value="固态硬盘">固态硬盘</option>
           <option value="电子分销">电子分销</option>
           <option value="企业级存储">企业级存储</option>
         </select>
-        <button class="btn btn-secondary"><i class="fas fa-sliders-h"></i> 高级筛选</button>
+        <button class="btn btn-secondary"><i class="fas fa-sliders-h"></i> 高级筛选</button> -->
       </div>
     </div>
 

+ 114 - 46
ui/smarttrade-platform/src/views/KnowledgeView.vue

@@ -110,7 +110,7 @@
       </div>
       <div class="tab-actions">
         <select class="form-select" v-if="activeTab=='all'" v-model="categoryId" @change="changeCategory" style="width: 250px;">
-          <option value="">选择分类</option>
+          <option value="">全部分类</option>
           <option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
         </select>
         <div class="search-box">
@@ -171,8 +171,7 @@
     <div v-if="activeTab === 'all'" class="section">
       <div class="knowledge-list">
         <div v-for="item in knowledgeList" :key="item.id"
-             class="knowledge-card"
-             @click="showDocumentDetail(item)">
+             class="knowledge-card">
           <!-- <div class="knowledge-icon" :style="{ background: item.iconBg }">{{ item.icon }}</div> -->
           <div class="knowledge-content">
             <div class="knowledge-header">
@@ -187,8 +186,14 @@
             </div> -->
           </div>
           <div class="knowledge-actions" @click.stop>
-            <button class="btn btn-secondary btn-sm" @click="editDocument(item)">
-              <i class="fas fa-edit"></i>
+            <!-- <button class="btn btn-secondary btn-sm" @click="detailDocument(item)">
+              <i class="fas fa-eye"></i>
+            </button> -->
+            <button class="btn btn-secondary btn-sm" @click="showDocumentDetail(item)">
+              <i class="fas fa-eye"></i>
+            </button>
+            <button class="btn btn-secondary btn-sm" @click="downloadDocuments(item)">
+              <i class="fas fa-download"></i>
             </button>
             <el-popconfirm title="确认删除?" @confirm="deleteKnowledge(item)">
               <template #reference>
@@ -409,12 +414,16 @@
         </div>
         <div class="modal-body">
           <div class="form-group">
-            <label class="form-label">问题/关键词 <span class="required">*</span></label>
-            <input type="text" class="form-input" v-model="newKnowledge.content" placeholder="例如:MOQ最小起订量是多少?">
+            <div style="display: flex;align-items: center;margin-bottom: 6px;justify-content: space-between;">
+              <label class="form-label">问题/关键词 <span class="required">*</span></label><button class="btn btn-primary" @click="aiOptimize('content')" style="margin-left: 10px;padding: 4px 8px;border-radius: 4px;" :disabled="aiContentLoading"><i class="fas fa-sync" :class="{'icon-spin': aiContentLoading}"></i> AI优化</button>
+            </div>
+            <textarea class="form-textarea" v-model="newKnowledge.content" rows="2" placeholder="例如:MOQ最小起订量是多少?" :disabled="aiContentLoading"></textarea>
           </div>
           <div class="form-group">
-            <label class="form-label">答案/内容 <span class="required">*</span></label>
-            <textarea class="form-textarea" v-model="newKnowledge.answer" rows="4" placeholder="输入详细答案..."></textarea>
+            <div style="display: flex;align-items: center;margin-bottom: 6px;justify-content: space-between;">
+              <label class="form-label">答案/内容 <span class="required">*</span></label><button class="btn btn-primary" @click="aiOptimize('answer')" style="margin-left: 10px;padding: 4px 8px;border-radius: 4px;" :disabled="aiAnswerLoading"><i class="fas fa-sync" :class="{'icon-spin': aiAnswerLoading}"></i> AI优化</button>
+            </div>
+            <textarea class="form-textarea" v-model="newKnowledge.answer" rows="4" placeholder="输入详细答案..." :disabled="aiAnswerLoading"></textarea>
           </div>
           <div class="form-group">
             <label class="form-label">知识分类 <span class="required">*</span></label>
@@ -555,8 +564,8 @@
         </div>
         <div class="modal-footer">
           <button class="btn btn-secondary" @click="showDetailModal = false">关闭</button>
-          <button class="btn btn-secondary" @click="editKnowledge(selectedKnowledge)"><i class="fas fa-edit"></i> 编辑</button>
-          <button class="btn btn-primary" @click="optimizeAnswer(selectedKnowledge)"><i class="fas fa-sync"></i> 优化答案</button>
+          <button class="btn btn-primary" @click="editKnowledge(selectedKnowledge)"><i class="fas fa-edit"></i> 编辑</button>
+          <!-- <button class="btn btn-primary" @click="optimizeAnswer(selectedKnowledge)"><i class="fas fa-sync"></i> 优化答案</button> -->
         </div>
       </div>
     </div>
@@ -667,7 +676,7 @@
 
 <script setup>
 import { ref, computed } from 'vue'
-import { knowledgeDatasets,knowledgeDocuments,createKnowledge,createChunk,editChunk,chatMessages,baseTraining,creatTraining,baseStatistics,getAllChunks,getAllDocumnets,delDocuments,delChunk } from '../api/knowledge'
+import { knowledgeDatasets,knowledgeDocuments,createKnowledge,createChunk,editChunk,chatMessages,baseTraining,creatTraining,baseStatistics,getAllChunks,getAllDocumnets,delDocuments,delChunk,getDocumentDetail,blocking,downloadDocument } from '../api/knowledge'
 import { formatTimeAgo } from '@/utils/time'
 import { ElMessage } from 'element-plus'
 
@@ -677,6 +686,8 @@ const showImportModal = ref(false)
 const showTrainModal = ref(false)
 const showChunksModal = ref(false)
 const chunkLoading = ref(false)
+const aiContentLoading = ref(false)
+const aiAnswerLoading = ref(false)
 const selectedKnowledge = ref(null)
 const selectedDocument = ref(null)
 const activeTab = ref('categories')
@@ -1030,43 +1041,29 @@ const showDocumentDetail = (item) => {
   getChunkDatasets()
 }
 
+const downloadDocuments = (item) =>{
+  let params = {
+    dataset_id:item.dataset_id,
+    document_id:item.id,
+  }
+   downloadDocument(params).then(res => {
+    if(res.success){
+      const url = res.result.url
+      // window.open(res.result.url, '_blank')
+      const a = document.createElement('a')
+      a.href = url
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+    }
+  })
+}
+
 const showKnowledgeDetail = (item) => {
   selectedKnowledge.value = item
   showDetailModal.value = true
 }
 
-
-
-// const filteredCategories = computed(() => {
-//   if (!searchQuery.value) return categories.value
-//   return categories.value.filter(c => c.name.includes(searchQuery.value) || c.description.includes(searchQuery.value))
-// })
-
-// const filteredKnowledge = computed(() => {
-//   if (!searchQuery.value) return knowledgeList.value
-//   return knowledgeList.value.filter(k => 
-//     k.question.includes(searchQuery.value) || 
-//     k.answer.includes(searchQuery.value) ||
-//     k.tags.some(t => t.includes(searchQuery.value))
-//   )
-// })
-
-const filteredQA = computed(() => {
-  let result = qaRecords.value
-  if (qaFilter.value !== 'all') {
-    result = result.filter(qa => qa.sourceType === qaFilter.value)
-  }
-  if (qaRating.value !== 'all') {
-    result = result.filter(qa => qa.rating === qaRating.value)
-  }
-  return result
-})
-
-const formatNumber = (num) => num.toLocaleString()
-
-
-
-
 const editKnowledge = (item) => {
   chunkType.value = 'edit'
   newKnowledge.value.content = item.content
@@ -1077,8 +1074,14 @@ const editKnowledge = (item) => {
   newKnowledge.value.document_id=item.document_id
   showAddModal.value = true
 }
-const editDocument = (item) => {
-  
+const detailDocument = (item) => {
+  let params = {
+    dataset_id:item.dataset_id,
+    document_id:item.id,
+  }
+   getDocumentDetail(params).then(res => {
+    console.log(555)
+  })
 }
 
 const getDocument = () =>{
@@ -1117,6 +1120,64 @@ const addKnowledge = () => {
   
 }
 
+const aiOptimize = (type) => {
+  let params = {
+    query:'',
+    response_mode:'blocking',
+    user:1,
+    appId:'app-KpoSEqve0ybB2Ej5PlJiBu2k',
+    conversation_id:'',
+    inputs:{},
+  }
+  if(type =='content'){
+    if(aiContentLoading.value) return
+    aiContentLoading.value = true
+    if(!newKnowledge.value.content){
+      return ElMessage.error('请输入问题/关键词')
+    }
+    newKnowledge.value.content = newKnowledge.value.content.split('\n')[0]
+    params.query = '你的任务是优化用户输入的问句,用于知识库问答检索。要求:1. 只输出优化后的问题,不要任何解释、前言、总结;2. 扩充同义问法、口语变体,提升检索命中;3. 保持原意不变,句式自然;4、给出一个方案即可。以下是用户原始问题:' + newKnowledge.value.content
+    blocking(params).then(res => {
+      aiContentLoading.value = false
+      if(res.success){
+        newKnowledge.value.content += '\n' + res.result.answer
+      }else{
+        return ElMessage.error('请求超时,请稍后再试')
+      }
+    })
+  }else{
+    if(aiAnswerLoading.value) return
+    aiAnswerLoading.value = true
+    if(!newKnowledge.value.answer){
+      return ElMessage.error('请输入答案/内容')
+    }
+    newKnowledge.value.answer = newKnowledge.value.answer.split('\n')[0]
+    params.query = '你的任务是优化用户输入的回答,用于知识库问答检索。要求:1. 只输出优化后的回答,不要任何解释、前言、总结;2. 扩充同义问法、口语变体,提升检索命中;3. 保持原意不变,句式自然;4、给出一个方案即可。以下是用户原始回答:' + newKnowledge.value.answer
+    blocking(params).then(res => {
+      aiAnswerLoading.value = false
+      if(res.success){
+        newKnowledge.value.answer += '\n' + res.result.answer
+      }else{
+        return ElMessage.error('请求超时,请稍后再试')
+      }
+    })
+  }
+}
+
+
+const filteredQA = computed(() => {
+  let result = qaRecords.value
+  if (qaFilter.value !== 'all') {
+    result = result.filter(qa => qa.sourceType === qaFilter.value)
+  }
+  if (qaRating.value !== 'all') {
+    result = result.filter(qa => qa.rating === qaRating.value)
+  }
+  return result
+})
+
+const formatNumber = (num) => num.toLocaleString()
+
 const optimizeAnswer = (item) => {
   showToastMessage('AI 正在优化答案...', 'success')
 }
@@ -1369,4 +1430,11 @@ const showToastMessage = (message, type = 'success') => {
 .pagination-area{
   padding: 40px 0;
 }
+.icon-spin {
+  animation: spin 1s linear infinite !important;
+}
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
 </style>

+ 15 - 7
ui/smarttrade-platform/src/views/MarketingView.vue

@@ -23,42 +23,42 @@
       <div class="stat-card-lg">
         <div class="stat-icon-lg blue"><i class="fas fa-paper-plane"></i></div>
         <div class="stat-content-lg">
-          <h3>{{ formatNumber(marketingStats.totalSent) }}</h3>
+          <h3>{{ statsData?.monthlySent }}</h3>
           <p>本月发送</p>
         </div>
       </div>
       <div class="stat-card-lg">
         <div class="stat-icon-lg green"><i class="fas fa-envelope-open"></i></div>
         <div class="stat-content-lg">
-          <h3>{{ marketingStats.openRate }}%</h3>
+          <h3>{{ statsData?.avgOpenRate }}</h3>
           <p>平均打开率</p>
         </div>
       </div>
       <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>{{ marketingStats.clickRate }}%</h3>
+          <h3>{{ statsData?.clickRate }}</h3>
           <p>点击率</p>
         </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">
-          <h3>{{ marketingStats.replyRate }}%</h3>
+          <h3>{{ statsData?.replyRate }}</h3>
           <p>回复率</p>
         </div>
       </div>
       <div class="stat-card-lg">
         <div class="stat-icon-lg success"><i class="fas fa-shield-alt"></i></div>
         <div class="stat-content-lg">
-          <h3>{{ marketingStats.protectedCustomers }}</h3>
+          <h3>{{ statsData?.protectedCustomers }}</h3>
           <p>受保护客户</p>
         </div>
       </div>
       <div class="stat-card-lg">
         <div class="stat-icon-lg warning"><i class="fas fa-exclamation-circle"></i></div>
         <div class="stat-content-lg">
-          <h3>{{ marketingStats.pendingReview }}</h3>
+          <h3>{{ statsData?.pendingReview }}</h3>
           <p>待审核</p>
         </div>
       </div>
@@ -1254,7 +1254,7 @@
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 import AddMarketing from '../components/addMarketing.vue'
-import { getMarketingList, getSopById } from '../api/marketing'
+import { getMarketingList, getSopById, marketingAnalysis } from '../api/marketing'
 
 const filterTab = ref('all')
 
@@ -1289,6 +1289,7 @@ const openCreateModal = () => {
 const marketingList = ref([])
 onMounted(() => {
   getData()
+  getStatsData()
 })
 
 const getData = () => {
@@ -1306,6 +1307,13 @@ const getData = () => {
   })
 }
 
+const statsData = ref()
+const getStatsData = () => {
+  marketingAnalysis().then(res => {
+    statsData.value = res.result
+  })
+}
+
 
 // History filters
 const historySearch = ref('')