ソースを参照

Merge remote-tracking branch 'origin/master'

zhujing 18 時間 前
親
コミット
783a4e2d13

+ 1 - 1
java/storlead-api/src/main/resources/application-dev.yml

@@ -95,7 +95,7 @@ spring:
           password: rCgRgLjH99Xvg5BN
           driver-class-name: com.mysql.jdbc.Driver
         trade:
-          url: jdbc:mysql://mysql.test.storlead.com:39091/sp_trade_platform_test?useSSL=false&useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true
+          url: jdbc:mysql://mysql.test.storlead.com:39091/sp_sales_test?useSSL=false&useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true
           username: root
           password: rCgRgLjH99Xvg5BN
           driver-class-name: com.mysql.jdbc.Driver

+ 31 - 1
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/config/AsyncConfig.java

@@ -1,10 +1,40 @@
 package com.storlead.trade.config;
 
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
 
 @Configuration
 @EnableAsync
 public class AsyncConfig {
     // 启用 Spring 异步支持
-}
+
+    /**
+     * 邮件内容生成(AI 重写正文)专用线程池。
+     * <p>
+     * 之前没有自定义 executor,Spring 会退化为 SimpleAsyncTaskExecutor:
+     * 每个任务 new 一条线程,一个 SOP 步骤几十上百封邮件就会瞬间起几十上百条线程打 AI,
+     * 既容易把 AI 服务打爆,也会拖垮本机。这里限制并发,并用队列兜底。
+     * <p>
+     * 队列满时走 CallerRunsPolicy:由提交任务的线程(请求线程)自己执行,任务不会丢,
+     * 但会退化成同步、接口变慢——日志里能看到线程名变成 http-nio-xxx 而不是 trade-email-x。
+     */
+    @Bean("tradeEmailExecutor")
+    public Executor tradeEmailExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(4);
+        executor.setMaxPoolSize(8);
+        executor.setQueueCapacity(2000);
+        executor.setKeepAliveSeconds(60);
+        executor.setThreadNamePrefix("trade-email-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        executor.setAwaitTerminationSeconds(30);
+        executor.initialize();
+        return executor;
+    }
+}

+ 5 - 5
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java

@@ -7,6 +7,7 @@ import com.storlead.trade.dto.MarketEmailsDTO;
 import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.service.MarketEmailsService;
 import com.storlead.framework.common.result.Result;
+import com.storlead.trade.vo.MarketEmailsAccountStatsVO;
 import com.storlead.trade.vo.MarketEmailsManagementVO;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
@@ -21,7 +22,6 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
-import java.util.Map;
 
 import javax.annotation.Resource;
 
@@ -165,10 +165,10 @@ public class MarketEmailsController {
         return marketEmailsService.getLast7DaysSendTrend();
     }
 
-    @GetMapping("/account-total")
-    @ApiOperation("营销: 按发件箱 id 批量统计,返回 Map<outboxId, 统计>(总发送量、回复数、回复率、最新发送时间;outboxIds=1,2 或 outboxIds=1&outboxIds=2)")
-    public Result<Map<Long, Object>> accountStats(@RequestParam("outboxIds") List<Long> outboxIds) {
-        return marketEmailsService.getAccountStats(outboxIds);
+    @PostMapping("/account-total")
+    @ApiOperation("营销: 按发件箱 id 批量统计,返回 List(总发送量、回复数、回复率、最新发送时间;outboxIds=1,2 或 outboxIds=1&outboxIds=2)")
+    public Result<List<MarketEmailsAccountStatsVO>> accountStats(@RequestParam("outboxIds") List<Long> outboxIds) {
+        return Result.result(marketEmailsService.getAccountStats(outboxIds));
     }
 
     @PostMapping("/test")

+ 9 - 3
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java

@@ -3,6 +3,7 @@ package com.storlead.trade.service;
 import com.storlead.trade.dto.MarketEmailsDTO;
 import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.entity.MarketingCampaignEntity;
+import com.storlead.trade.vo.MarketEmailsAccountStatsVO;
 import com.storlead.trade.vo.MarketEmailsCustomerVO;
 import com.storlead.trade.vo.MarketEmailsManagementVO;
 import com.storlead.trade.vo.MarketEmailsStatsVO;
@@ -12,7 +13,6 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import org.springframework.scheduling.annotation.Async;
 
 import java.util.List;
-import java.util.Map;
 
 public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
 
@@ -113,9 +113,9 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
     /**
      * 按发件箱 id 批量聚合:总发送量、回复数、回复率、最新发送时间。
      * 口径与今日发送一致:is_delete=0 且 status=1;回复数再叠加 has_reply=1。
-     * 返回 Map&lt;outboxId, 统计&gt;;入参只接受账号 id,无数据的 id 补 0。
+     * 入参只接受账号 id,无数据的 id 补 0,顺序与去重后的 outboxIds 一致。
      */
-    Result<Map<Long, Object>> getAccountStats(List<Long> outboxIds);
+    List<MarketEmailsAccountStatsVO> getAccountStats(List<Long> outboxIds);
 
     /***
       * @Description: 通过id/marketingCampaignId/客户id关联的邮件状态
@@ -159,6 +159,12 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
      */
     int cancelOverduePending();
 
+    /**
+     * 异步生成邮件正文并落库(一次 AI 调用)。
+     * <p>
+     * 真正生效的 @Async 在实现类方法上({@code MarketEmailsServiceImpl#updateEmailContent}),
+     * 并指定了 {@code tradeEmailExecutor} 线程池;这里的注解仅作说明,保留以免漏看。
+     */
     @Async
     void updateEmailContent(MarketEmailsEntity marketEmailsEntity);
 }

+ 105 - 45
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java

@@ -33,6 +33,7 @@ import org.slf4j.Logger;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
@@ -538,42 +539,71 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return rows;
     }
 
+    /**
+     * 生成邮件正文(调 AI 重写内容)并落库。
+     * <p>
+     * 一次调用 = 一次 AI 请求,耗时通常是秒级,一个 SOP 步骤下可能有几十上百封邮件,
+     * 所以这里必须是异步的:@Async 放在<strong>实现类方法</strong>上(放在接口方法上时,
+     * 一旦 bean 走 CGLIB 代理或与其它 AOP 代理叠加,匹配行为会随代理方式变化,不如放实现类稳妥)。
+     * <p>
+     * 异步线程的异常不会回到请求线程,因此内部自行兜住并记 error 日志,
+     * 避免失败后邮件一直停在旧内容上却没有任何痕迹。
+     */
     @Override
+    @Async("tradeEmailExecutor")
     public void updateEmailContent(MarketEmailsEntity marketEmailsEntity) {
-        //通过客户id获取客户分析结果
-        CustomerAnalysisResultEntity customerAnalysisResult = new CustomerAnalysisResultEntity();
-        LambdaQueryWrapper<CustomerAnalysisResultEntity> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(CustomerAnalysisResultEntity::getCustomerId, marketEmailsEntity.getCustomerId());
-        queryWrapper.last("limit 1");
-        customerAnalysisResult = customerAnalysisResultService.getOne(queryWrapper);
-        String reasonSummary = "";
-        if (customerAnalysisResult != null){
-            reasonSummary = customerAnalysisResult.getReasonSummary();
-        }
-        Map sendMap = new HashMap();
-        sendMap.put("email", marketEmailsEntity.getContent());
-        sendMap.put("reason_summary", reasonSummary);
-
-        ChatDTO chatDTO = new ChatDTO();
-        chatDTO.setInputs(sendMap);
-//            chatDTO.setQuery("send_time");
-        chatDTO.setResponseMode("blocking");
-        chatDTO.setAppId("app-CVPtLUPO0zd2SeGPvZv3Q4Ev");
-        chatDTO.setUser("1");
-
-        // 调取 AI  获取新的content
-        Result<Object> r = workflowsService.AiWorkflows(chatDTO);
-        if (r == null) {
-            log.warn("无内容生成");
-            return;
-        }
-        // 从结构化结果里取出 AI 真正输出的 text 字段(多个结束节点拼接)。
-        // 不再做 inner.toString() 兜底——整串 Result 里有 files=[]/inputs=[] 等空数组
-        // 会被误当成 AI 输出的 JSON 数组(如 [models]),污染解析。
-        String text = extractCleanText(r.getResult());
-        marketEmailsEntity.setContent(text);
+        log.info("[updateEmailContent] 提交异步生成,emailId={},customerId={},线程={}",
+                marketEmailsEntity.getId(), marketEmailsEntity.getCustomerId(), Thread.currentThread().getName());
+        try {
+            //通过客户id获取客户分析结果
+            CustomerAnalysisResultEntity customerAnalysisResult = new CustomerAnalysisResultEntity();
+            LambdaQueryWrapper<CustomerAnalysisResultEntity> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(CustomerAnalysisResultEntity::getCustomerId, marketEmailsEntity.getCustomerId());
+            queryWrapper.last("limit 1");
+            customerAnalysisResult = customerAnalysisResultService.getOne(queryWrapper);
+            String reasonSummary = "";
+            if (customerAnalysisResult != null) {
+                reasonSummary = customerAnalysisResult.getReasonSummary();
+            }
+            Map sendMap = new HashMap();
+            sendMap.put("email", marketEmailsEntity.getContent());
+            sendMap.put("reason_summary", reasonSummary);
+
+            ChatDTO chatDTO = new ChatDTO();
+            chatDTO.setInputs(sendMap);
+            chatDTO.setResponseMode("blocking");
+            chatDTO.setAppId("app-CVPtLUPO0zd2SeGPvZv3Q4Ev");
+            chatDTO.setUser("1");
 
-        this.updateById(marketEmailsEntity);
+            // 调取 AI  获取新的content
+            Result<Object> r = workflowsService.AiWorkflows(chatDTO);
+            if (r == null) {
+                log.warn("无内容生成,emailId={}", marketEmailsEntity.getId());
+                return;
+            }
+            // 从结构化结果里取出 AI 真正输出的 text 字段(多个结束节点拼接)。
+            // 不再做 inner.toString() 兜底——整串 Result 里有 files=[]/inputs=[] 等空数组
+            // 会被误当成 AI 输出的 JSON 数组(如 [models]),污染解析。
+            String text = extractCleanText(r.getResult());
+            // 去掉 <think>...</think>(含标签本身)。思考块是多行的,必须用 stripThinkBlocks 里的 DOTALL 正则,
+            // 直接写 replaceAll("<think>.*?</think>", "") 会因为 . 不匹配换行而整段删不掉。
+            text = stripThinkBlocks(text);
+            if (text == null) {
+                log.warn("AI 返回内容为空,emailId={}", marketEmailsEntity.getId());
+                return;
+            }
+            // 删掉思考块后正文开头/结尾常留下一串空行:连续 3 个以上换行压成 2 个(保留段落间隔),再去首尾空白
+            text = text.replaceAll("\\n{3,}", "\n\n").trim();
+            marketEmailsEntity.setContent(text);
+
+            this.updateById(marketEmailsEntity);
+            log.info("[updateEmailContent] 生成完成并已落库,emailId={},线程={}",
+                    marketEmailsEntity.getId(), Thread.currentThread().getName());
+        } catch (Exception e) {
+            // 异步线程:异常不会回到请求线程,这里必须自己兜住,否则失败将毫无痕迹
+            log.error("[updateEmailContent] 生成邮件内容失败,emailId={},customerId={}",
+                    marketEmailsEntity.getId(), marketEmailsEntity.getCustomerId(), e);
+        }
     }
 
     /**
@@ -1055,17 +1085,20 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     }
 
     @Override
-    public Result<Map<Long, Object>> getAccountStats(List<Long> outboxIds) {
-        Map<Long, Object> result = new LinkedHashMap<>();
+    public List<MarketEmailsAccountStatsVO> getAccountStats(List<Long> outboxIds) {
+        List<MarketEmailsAccountStatsVO> result = new ArrayList<>();
         if (CollectionUtils.isEmpty(outboxIds)) {
-            return Result.result(result);
+            return result;
         }
         List<Long> ids = outboxIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
         if (ids.isEmpty()) {
-            return Result.result(result);
+            return result;
         }
+        Map<Long, MarketEmailsAccountStatsVO> byId = new LinkedHashMap<>();
         for (Long id : ids) {
-            result.put(id, emptyAccountStats(id));
+            MarketEmailsAccountStatsVO vo = emptyAccountStats(id);
+            byId.put(id, vo);
+            result.add(vo);
         }
 
         QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
@@ -1080,27 +1113,25 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         qw.groupBy("outbox_id");
         List<Map<String, Object>> rows = this.listMaps(qw);
         if (CollectionUtils.isEmpty(rows)) {
-            return Result.result(result);
+            return result;
         }
         for (Map<String, Object> row : rows) {
             if (row == null) {
                 continue;
             }
             Long id = toLongOrNull(row.get("outbox_id"));
-            if (id == null || !result.containsKey(id)) {
+            MarketEmailsAccountStatsVO vo = id == null ? null : byId.get(id);
+            if (vo == null) {
                 continue;
             }
-            MarketEmailsAccountStatsVO vo = new MarketEmailsAccountStatsVO();
             int sendCount = toInteger(row.get("send_count"));
             int replyCount = toInteger(row.get("reply_count"));
-            vo.setOutboxId(id);
             vo.setSendCount(sendCount);
             vo.setReplyCount(replyCount);
             vo.setReplyRate(toReplyRate(sendCount, replyCount));
             vo.setLastSendTime(toDate(row.get("last_send_time")));
-            result.put(id, vo);
         }
-        return Result.result(result);
+        return result;
     }
 
     private MarketEmailsAccountStatsVO emptyAccountStats(Long outboxId) {
@@ -1796,6 +1827,35 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return t == null ? null : t.toString();
     }
 
+    /**
+     * 剥掉 AI 输出里的思考块。
+     * <p>
+     * 规则:<strong>凡是落在 {@code <think>} 与 {@code </think>} 之间的内容,连同这两个标签本身一起删掉</strong>;
+     * 标签之外的正文原样保留(多个思考块夹着正文时,只删块、保留正文)。
+     * <p>
+     * 两个坑:
+     * <ol>
+     *   <li><strong>必须开 DOTALL</strong>。模型的思考过程几乎都是多行的,而 Java 正则里 {@code .} 默认
+     *       不匹配 {@code \n}。写成 {@code "<think>.*?</think>"} 时多行块整个匹配不上,
+     *       结果就是一整段思考过程原样留在邮件正文里 —— 本次的 bug 就在这里。</li>
+     *   <li>兼容"只有 {@code <think>} 没有 {@code </think>}"的截断情况(思考过程超长被砍断很常见),
+     *       此时从开标签一直删到文本结尾,避免思考片段混进正文。</li>
+     * </ol>
+     *
+     * @param text 原始文本,允许为 null
+     * @return 去掉思考块后的文本;null 原样返回
+     */
+    private String stripThinkBlocks(String text) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        // (?s) 让 . 匹配换行;(?i) 兼容 <Think>;[^>]* 兼容带属性的开标签(如 <think lang="zh">)
+        // (?:...</think>|$) 兜底未闭合:有闭合标签就截到闭合处,没有就截到结尾
+        String cleaned = text.replaceAll("(?si)<think\\b[^>]*>[\\s\\S]*?(?:</think>|$)", "");
+        // 再兜一层:清掉可能残留的孤立标签(比如正文里孤零零一个 </think>)
+        return cleaned.replaceAll("(?i)</?think\\b[^>]*>", "");
+    }
+
     /**
      * 从 AI 文本中解析排期阶段列表。
      * 兼容:思考块包裹、json 代码块、裸数组、全角标点/括号,以及模型把输出数组重复拼接的情况。
@@ -1919,7 +1979,7 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
      */
     private List<String> extractJsonCandidates(String text) {
         List<String> result = new ArrayList<>();
-        String cleaned = text.replaceAll("(?s)<think>[\\s\\S]*?</think>", "");
+        String cleaned = stripThinkBlocks(text);
         int from = 0;
         while (true) {
             int start = cleaned.indexOf('[', from);

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

@@ -264,6 +264,7 @@ public class MarketingCampaignServiceImpl
         List<MarketEmailsEntity> campaignEmails = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getHasFirst, 1)
+                .ne(MarketEmailsEntity::getStatus,-3)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
                 .list();
         for (MarketEmailsEntity email : campaignEmails) {

+ 2 - 1
ui/smarttrade-platform/src/api/emails/index.js

@@ -3,4 +3,5 @@ import { get, post, put, remove, upload } from '@/utils/request'
 export const getMailsPort = (mailAccount) => post('/router/rest/mail/common/mail_port_config', { mailAccount })
 export const emailsTest = (data) => post('/router/rest/mkt/email/account/test_connect', data)
 export const addEmailsSetting = (data) => post('/router/rest/mkt/email/account/save', data)
-export const getUserEmails = () => post('/router/rest/mkt/email/account/personal_mail_account')
+export const getUserEmails = () => post('/router/rest/mkt/email/account/personal_mail_account')
+export const getAccountTotal = (data) => post('/router/rest/marketEmails/account-total', data)

+ 23 - 7
ui/smarttrade-platform/src/components/email/email.vue

@@ -18,14 +18,14 @@
             <div class="overview-card">
               <div class="overview-icon"><i class="fas fa-paper-plane"></i></div>
               <div class="overview-info">
-                <div class="overview-value">{{ emailAccounts.reduce((sum, a) => sum + a.sendToday, 0) }}</div>
-                <div class="overview-label">今日发送</div>
+                <div class="overview-value">{{ total.sendTotal }}</div>
+                <div class="overview-label">总发送</div>
               </div>
             </div>
             <div class="overview-card">
               <div class="overview-icon"><i class="fas fa-reply"></i></div>
               <div class="overview-info">
-                <div class="overview-value">{{ Math.round(emailAccounts.reduce((sum, a) => sum + a.replyRate, 0) / emailAccounts.length) }}%</div>
+                <div class="overview-value">{{ Math.round(total.replyRate) }}%</div>
                 <div class="overview-label">平均回复率</div>
               </div>
             </div>
@@ -51,9 +51,9 @@
                 <div class="account-email">{{ account.emailAddress }}</div>
                 <div class="account-name">销售邮箱</div>
                 <div class="account-stats">
-                  <span><i class="fas fa-paper-plane"></i> 总发送: 45</span>
-                  <span><i class="fas fa-reply"></i> 回复率: 32%</span>
-                  <span><i class="fas fa-sync"></i>  2026-03-24 09:00</span>
+                  <span><i class="fas fa-paper-plane"></i> 总发送: {{ account.accountTotal.sendCount }}</span>
+                  <span><i class="fas fa-reply"></i> 回复率: {{ account.accountTotal.replyRate * 100 }}%</span>
+                  <span v-if="account.accountTotal.lastSendTime"><i class="fas fa-sync"></i>  {{ account.accountTotal.lastSendTime }}</span>
                 </div>
               </div>
               <!-- <div class="account-auth">
@@ -255,7 +255,7 @@
 <script setup>
     import { ref } from 'vue'
     import AddEmailModal from './addEmail.vue'
-    import { getMailsPort } from '@/api/emails'
+    import { getMailsPort, getAccountTotal } from '@/api/emails'
 
     const showEmailModal = ref(false)
     // 企业邮箱列表
@@ -265,9 +265,25 @@
     ])
 
     const emailList = ref([])
+    const total = ref({
+      sendTotal: 0,
+      replyRate: 0
+    })
     const open = (list) => {
         showEmailModal.value = true
         emailList.value = list || []
+        let params = {
+          outboxIds: emailList.value.map(item => item.id).join(',')
+        }
+        if(emailList.value.length != 0) {
+          getAccountTotal(params).then(res => {
+            emailList.value.map(item => {
+              item.accountTotal = res.result.find(acc => acc.outboxId === item.id) || {}
+            })
+            total.value.sendTotal = emailList.value.reduce((sum, a) => sum + (a.accountTotal.sendCount || 0), 0)
+            total.value.replyRate = emailList.value.reduce((sum, a) => sum + (a.accountTotal.replyRate * 100 || 0), 0) / emailList.value.length
+          })
+        }
     }
 
     const tabTyps = ref('email')

+ 10 - 8
ui/smarttrade-platform/src/components/marketing/aiGenerator.vue

@@ -309,7 +309,8 @@
             let subject = ''
             let waitingForSubject = false
             let readingSubject = false
-            while (true) {
+            let streamFinished = false
+            while (!streamFinished) {
                 const { done, value } = await reader.read();
                 if (done) break;
 
@@ -328,7 +329,8 @@
                     if (trimmedLine === 'data: [DONE]') {
                         console.log('流传输结束');
                         doneData += '</p>'
-                        return;
+                        streamFinished = true
+                        break
                     }
 
                     if (trimmedLine.startsWith('data:')) {
@@ -361,14 +363,14 @@
                                 subject += data.slice(0, newlineIndex)
                                 readingSubject = false
                                 const contentAfterSubject = data.slice(newlineIndex + 1)
-                                if (contentAfterSubject.trim()) {
-                                    doneData += contentAfterSubject
+                                if (contentAfterSubject) {
+                                    doneData += contentAfterSubject.replace(/\\n|\r\n?|\n/g, '</p><p>')
                                 } else {
                                     doneData += '</p><p>'
                                 }
-                            } else if(trimmedData){
-                                doneData += data
-                            }else{
+                            } else if (data) {
+                                doneData += data.replace(/\\n|\r\n?|\n/g, '</p><p>')
+                            } else {
                                 doneData += '</p><p>'
                             }
                         } catch (e) {
@@ -377,7 +379,7 @@
                     }
                 }
             }
-            emailContent.value = doneData.replace('<p></p>', '')
+            emailContent.value = doneData.replace(/<p>\s*<\/p>/g, '')
             emailSubject.value = subject.replace(': ', '')
             isGenerating.value = false
             ElMessage.success('生成成功')

+ 68 - 1
ui/smarttrade-platform/src/views/emails/edit.vue

@@ -104,13 +104,80 @@
         form.value.content = val
     }
 
+    const removeEmptyParagraphs = (html) => {
+        const container = document.createElement('div')
+        container.innerHTML = html
+        container.querySelectorAll('p').forEach(paragraph => {
+            if (!paragraph.textContent.trim() && !paragraph.querySelector('img, video, iframe, table')) {
+                paragraph.remove()
+            }
+        })
+        container.querySelectorAll('br').forEach(lineBreak => {
+            let nextSibling = lineBreak.nextSibling
+            while (nextSibling?.nodeType === Node.TEXT_NODE && !nextSibling.textContent.trim()) {
+                const whitespaceNode = nextSibling
+                nextSibling = nextSibling.nextSibling
+                whitespaceNode.remove()
+            }
+            while (nextSibling?.nodeName === 'BR') {
+                const duplicateLineBreak = nextSibling
+                nextSibling = nextSibling.nextSibling
+                duplicateLineBreak.remove()
+            }
+        })
+        return container.innerHTML
+    }
+
+    const formatEditorContent = (content) => {
+        const normalizedContent = String(content || '').replace(/\r\n?/g, '\n')
+        if (!normalizedContent) {
+            return normalizedContent
+        }
+
+        const lineBreakPattern = /\\n|\n/g
+        if (/<\/?[a-z][^>]*>/i.test(normalizedContent)) {
+            const container = document.createElement('div')
+            container.innerHTML = normalizedContent
+            const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
+            const textNodes = []
+
+            while (walker.nextNode()) {
+                textNodes.push(walker.currentNode)
+            }
+
+            textNodes.forEach(textNode => {
+                if (!textNode.nodeValue.trim()) return
+
+                const lines = textNode.nodeValue.split(lineBreakPattern).filter(line => line.trim())
+                if (lines.length === 1) return
+
+                const fragment = document.createDocumentFragment()
+                lines.forEach((line, index) => {
+                    if (line) fragment.append(document.createTextNode(line))
+                    if (index < lines.length - 1) fragment.append(document.createElement('br'))
+                })
+                textNode.replaceWith(fragment)
+            })
+
+            return removeEmptyParagraphs(container.innerHTML)
+        }
+
+        const container = document.createElement('div')
+        container.textContent = normalizedContent
+        const formattedContent = container.innerHTML
+            .split(lineBreakPattern)
+            .map(line => `<p>${line || '&nbsp;'}</p>`)
+            .join('')
+        return removeEmptyParagraphs(formattedContent)
+    }
+
     const router = useRouter();
     let queryData = router.currentRoute.value.query
     const getData = () => {
         getEmailById({ id: queryData.id }).then(res => {
             form.value.recipient = res.result.recipient
             form.value.subject = res.result.subject
-            form.value.content = res.result.content
+            form.value.content = formatEditorContent(res.result.content)
         })
     }