From 6a13b47328364080d1eda5deb529063b19fcac92 Mon Sep 17 00:00:00 2001 From: jqb Date: Tue, 4 Aug 2026 09:49:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E7=AB=AF=E5=AE=A2=E6=88=B7=E6=80=BB=E7=BB=93=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增客户联系方式查询客户每日总结的完整功能,包括前端页面、后端接口和数据库字段支持 2. 优化原有每日总结页面,新增绑定客户联系方式功能和无总结时的聊天记录兜底展示 3. 修复消息查询时的to_user数组匹配问题,新增客户微信号存储字段 4. 补充批量修复客户昵称的脚本和部署文档更新 --- AGENTS.md | 7 +- .../controller/ArchiveMessageController.java | 54 ++ .../controller/CustomerContactController.java | 98 +++ .../controller/DailySummaryController.java | 48 ++ .../com/artedu/archive/entity/Customer.java | 1 + .../archive/mapper/ArchiveMessageMapper.java | 24 +- .../service/ArchiveSessionService.java | 13 +- .../archive/service/DailySummaryService.java | 18 + .../archive/vo/CustomerDailySummaryVO.java | 25 + fix_customer_names_server.py | 134 ++++ frontend/sidebar/nginx.conf | 7 +- frontend/sidebar/src/SummaryApp.tsx | 578 ++++++------------ frontend/sidebar/src/SummarySearchApp.tsx | 296 +++++++++ .../src/components/ChatMessageList.tsx | 140 +++++ .../sidebar/src/components/SummaryCard.tsx | 374 ++++++++++++ frontend/sidebar/src/services/http.ts | 15 + frontend/sidebar/src/summary-search.tsx | 10 + frontend/sidebar/summary-search.html | 16 + frontend/sidebar/vite.config.ts | 3 +- init.sql | 1 + 20 files changed, 1454 insertions(+), 408 deletions(-) create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/controller/CustomerContactController.java create mode 100644 backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerDailySummaryVO.java create mode 100644 fix_customer_names_server.py create mode 100644 frontend/sidebar/src/SummarySearchApp.tsx create mode 100644 frontend/sidebar/src/components/ChatMessageList.tsx create mode 100644 frontend/sidebar/src/components/SummaryCard.tsx create mode 100644 frontend/sidebar/src/services/http.ts create mode 100644 frontend/sidebar/src/summary-search.tsx create mode 100644 frontend/sidebar/summary-search.html diff --git a/AGENTS.md b/AGENTS.md index de4e436..91f62eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,11 +48,15 @@ cat > index.html << HTML HTML # 复制到容器并重启 docker cp . ai-assistant-sidebar:/usr/share/nginx/html/ -docker exec ai-assistant-sidebar nginx -s reload +# 若 frontend/sidebar/nginx.conf 有变更(如新增页面路由),需同步到容器再 reload +docker cp ../nginx.conf ai-assistant-sidebar:/etc/nginx/conf.d/default.conf +docker exec ai-assistant-sidebar nginx -t && docker exec ai-assistant-sidebar nginx -s reload docker restart ai-assistant-nginx EOF ``` +> 多页说明:sidebar 是 Vite 多页应用(`vite.config.ts` rollupOptions.input)。`/sidebar/summary` 为每日总结页,`/sidebar/summary-search` 为运营按手机号/微信号查询客户总结的页面;新增页面时需在 `frontend/sidebar/nginx.conf` 加对应的精确匹配 `location = /xxx { try_files /xxx.html =404; }`。 + --- ## 二、后台 Admin 部署 @@ -136,3 +140,4 @@ EOF - **前端站点**: `https://ai.9artedu.com/sidebar/` - **后台站点**: `https://ai.9artedu.com/admin/` - **代理**: 宝塔 Nginx → Docker Nginx (8089) → 各容器 +- **生产数据库**: 各微服务连接的是**腾讯云 MySQL**(`MYSQL_HOST`/`MYSQL_PORT`/`MYSQL_USERNAME`/`MYSQL_PASSWORD` 见容器 env,库名 `ai_assistant`),**不是**服务器上的 `ai-assistant-mysql` 容器(那是闲置的本地实例)。线上改表结构(ALTER TABLE 等)必须打到腾讯云实例上,可从服务器用 `docker exec ai-assistant-mysql mysql -h <云DB地址> ...` 执行。 diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java index da4cab3..8d49c79 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java @@ -368,6 +368,60 @@ public class ArchiveMessageController { return userId != null && (userId.startsWith("wm") || userId.startsWith("wo")); } + /** + * 查询客户最近的单聊消息(每日总结为空时的兜底展示) + * + * @param customerId 客户企微外部联系人 userid + * @param staffId 销售老师企微 userId,传入则只查该老师与客户的单聊,否则查与客户的所有单聊 + * @param limit 最多返回条数,默认 10,上限 50 + * @return 消息列表,按 seq 倒序(最新在前) + */ + @GetMapping("/messages/recent-chat") + public Result> recentChat( + @RequestParam String customerId, + @RequestParam(required = false) String staffId, + @RequestParam(defaultValue = "10") Integer limit) { + + if (customerId.trim().isEmpty()) { + return Result.fail("customerId 不能为空"); + } + int max = Math.min(Math.max(limit, 1), 50); + String c = customerId.trim(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ArchiveMessage::getCorpId, corpId); + // 只查单聊,排除群消息 + wrapper.and(w -> w.isNull(ArchiveMessage::getRoomid).or().eq(ArchiveMessage::getRoomid, "")); + + // 匹配与客户的往来消息(to_user 可能是单值或 JSON 数组字符串) + if (staffId != null && !staffId.trim().isEmpty()) { + String s = staffId.trim(); + wrapper.and(w -> w + .and(a -> a.eq(ArchiveMessage::getFromUser, c) + .and(b -> b.eq(ArchiveMessage::getToUser, s) + .or().like(ArchiveMessage::getToUser, "\"" + s + "\"") + .or().like(ArchiveMessage::getTolist, "\"" + s + "\""))) + .or(a -> a.eq(ArchiveMessage::getFromUser, s) + .and(b -> b.eq(ArchiveMessage::getToUser, c) + .or().like(ArchiveMessage::getToUser, "\"" + c + "\"") + .or().like(ArchiveMessage::getTolist, "\"" + c + "\"")))); + } else { + wrapper.and(w -> w.eq(ArchiveMessage::getFromUser, c) + .or().eq(ArchiveMessage::getToUser, c) + .or().like(ArchiveMessage::getToUser, "\"" + c + "\"") + .or().like(ArchiveMessage::getTolist, "\"" + c + "\"")); + } + + wrapper.orderByDesc(ArchiveMessage::getSeq); + wrapper.last("LIMIT " + max); + + List messages = archiveMessageMapper.selectList(wrapper); + List list = messages.stream().map(this::toVO).collect(Collectors.toList()); + // 补充发送人/接收人昵称(客户昵称、销售老师名称) + enrichSenderNames(list); + return Result.success(list); + } + /** * 获取消息详情 */ diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/CustomerContactController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/CustomerContactController.java new file mode 100644 index 0000000..a514369 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/CustomerContactController.java @@ -0,0 +1,98 @@ +package com.artedu.archive.controller; + +import com.artedu.archive.entity.Customer; +import com.artedu.archive.mapper.CustomerMapper; +import com.artedu.common.result.Result; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 客户联系方式(手机号/微信号)绑定接口 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/archive/customer") +public class CustomerContactController { + + private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); + + @Autowired + private CustomerMapper customerMapper; + + @Value("${wecom.archive.corp-id}") + private String corpId; + + /** + * 查询客户已绑定的联系方式(绑定表单预填用) + */ + @GetMapping("/contact") + public Result> getContact(@RequestParam String customerId) { + Customer customer = customerMapper.selectByCustomerId(customerId, corpId); + if (customer == null) { + return Result.fail("客户不存在"); + } + Map data = new HashMap<>(); + data.put("customerId", customer.getCustomerId()); + data.put("name", customer.getName() != null ? customer.getName() : ""); + data.put("phone", customer.getPhone() != null ? customer.getPhone() : ""); + data.put("wechatId", customer.getWechatId() != null ? customer.getWechatId() : ""); + return Result.success(data); + } + + /** + * 绑定/更新客户的手机号、微信号 + * phone / wechatId 传 null 表示不更新该字段,传空串表示清除 + */ + @PostMapping("/bind-contact") + public Result bindContact(@RequestBody Map body) { + String customerId = body.get("customerId"); + if (customerId == null || customerId.trim().isEmpty()) { + return Result.fail("customerId 不能为空"); + } + customerId = customerId.trim(); + + boolean hasPhone = body.containsKey("phone"); + boolean hasWechatId = body.containsKey("wechatId"); + String phone = body.get("phone") != null ? body.get("phone").trim() : null; + String wechatId = body.get("wechatId") != null ? body.get("wechatId").trim() : null; + + boolean phoneEmpty = phone == null || phone.isEmpty(); + boolean wechatEmpty = wechatId == null || wechatId.isEmpty(); + if (phoneEmpty && wechatEmpty) { + return Result.fail("手机号、微信号至少填写一项"); + } + if (!phoneEmpty && !PHONE_PATTERN.matcher(phone).matches()) { + return Result.fail("手机号格式不正确"); + } + + Customer customer = customerMapper.selectByCustomerId(customerId, corpId); + if (customer == null) { + return Result.fail("客户不存在,请先同步客户"); + } + + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(Customer::getId, customer.getId()); + if (hasPhone) { + wrapper.set(Customer::getPhone, phone); + } + if (hasWechatId) { + wrapper.set(Customer::getWechatId, wechatId); + } + customerMapper.update(null, wrapper); + + log.info("Bind contact success, customerId={}, phone={}, wechatId={}", customerId, phone, wechatId); + return Result.success("绑定成功"); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java index cddb422..62b5bfd 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/DailySummaryController.java @@ -1,15 +1,21 @@ package com.artedu.archive.controller; +import com.artedu.archive.entity.Customer; +import com.artedu.archive.mapper.CustomerMapper; import com.artedu.archive.service.DailySummaryService; +import com.artedu.archive.vo.CustomerDailySummaryVO; import com.artedu.archive.vo.DailySummaryVO; import com.artedu.common.result.Result; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; import java.util.List; /** @@ -23,6 +29,12 @@ public class DailySummaryController { @Autowired private DailySummaryService dailySummaryService; + @Autowired + private CustomerMapper customerMapper; + + @Value("${wecom.archive.corp-id}") + private String corpId; + /** * 查询客户指定日期的消息总结 * @@ -51,4 +63,40 @@ public class DailySummaryController { List list = dailySummaryService.queryAllDailySummary(customerId, staffId); return Result.success(list); } + + /** + * 按手机号/微信号查询客户的所有每日总结(不按员工过滤,供运营使用) + * + * @param keyword 手机号或微信号,精确匹配 customers.phone / customers.wechat_id + * @return 按客户分组的总结列表,无匹配时返回空列表 + */ + @GetMapping("/daily-summary/by-contact") + public Result> dailySummaryByContact(@RequestParam String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return Result.fail("keyword 不能为空"); + } + String kw = keyword.trim(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Customer::getCorpId, corpId) + .and(w -> w.eq(Customer::getPhone, kw).or().eq(Customer::getWechatId, kw)); + List customers = customerMapper.selectList(wrapper); + + List result = new ArrayList<>(); + for (Customer customer : customers) { + CustomerDailySummaryVO vo = new CustomerDailySummaryVO(); + vo.setCustomerId(customer.getCustomerId()); + vo.setCustomerName(customer.getName()); + vo.setPhone(customer.getPhone()); + vo.setWechatId(customer.getWechatId()); + try { + vo.setList(dailySummaryService.queryAllDailySummaryByCustomer(customer.getCustomerId())); + } catch (Exception e) { + log.error("Query daily summary by customer failed, customerId={}", customer.getCustomerId(), e); + vo.setList(new ArrayList<>()); + } + result.add(vo); + } + return Result.success(result); + } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java index 6ce3334..df04a2e 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java @@ -25,6 +25,7 @@ public class Customer { private String type; private String gender; private String phone; + private String wechatId; private String unionid; private LocalDateTime addTime; private String status; diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java index e7f614c..bb73fd2 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java @@ -49,7 +49,8 @@ public interface ArchiveMessageMapper extends BaseMapper { @Select("SELECT DISTINCT to_user FROM archive_messages WHERE corp_id = #{corpId} AND to_user IS NOT NULL AND to_user != ''") List selectDistinctToUsers(@Param("corpId") String corpId); - @Select("SELECT * FROM archive_messages WHERE (from_user = #{customerId} OR to_user = #{customerId}) AND corp_id = #{corpId} ORDER BY msgtime DESC LIMIT #{limit}") + @Select("SELECT * FROM archive_messages WHERE (from_user = #{customerId} OR to_user = #{customerId} " + + "OR to_user = CONCAT('[\"', #{customerId}, '\"]')) AND corp_id = #{corpId} ORDER BY msgtime DESC LIMIT #{limit}") List selectLatestByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId, @Param("limit") int limit); @Select("SELECT * FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId} ORDER BY msgtime ASC LIMIT #{offset}, #{limit}") @@ -58,30 +59,39 @@ public interface ArchiveMessageMapper extends BaseMapper { @Select("SELECT COUNT(*) FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId}") Long countBySessionId(@Param("sessionId") String sessionId, @Param("corpId") String corpId); + @Select("SELECT * FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId} ORDER BY msgtime DESC LIMIT 1") + ArchiveMessage selectLatestBySessionId(@Param("sessionId") String sessionId, @Param("corpId") String corpId); + @Select("SELECT session_id, COUNT(*) as cnt, MAX(msgtime) as last_time " + - "FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId}) " + + "FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId} " + + "OR to_user = CONCAT('[\"', #{userId}, '\"]')) " + "AND session_id IS NOT NULL GROUP BY session_id ORDER BY last_time DESC LIMIT #{offset}, #{limit}") List> selectSessionsByUser(@Param("userId") String userId, @Param("corpId") String corpId, @Param("offset") int offset, @Param("limit") int limit); - @Select("SELECT COUNT(DISTINCT session_id) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId}) AND session_id IS NOT NULL") + @Select("SELECT COUNT(DISTINCT session_id) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId} " + + "OR to_user = CONCAT('[\"', #{userId}, '\"]')) AND session_id IS NOT NULL") Long countSessionsByUser(@Param("userId") String userId, @Param("corpId") String corpId); @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND from_user = #{staffId} AND DATE(FROM_UNIXTIME(msgtime/1000)) = CURDATE()") Integer countTodayMsgByStaff(@Param("staffId") String staffId, @Param("corpId") String corpId); - @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{customerId} OR to_user = #{customerId}) AND DATE(FROM_UNIXTIME(msgtime/1000)) = CURDATE()") + @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{customerId} OR to_user = #{customerId} " + + "OR to_user = CONCAT('[\"', #{customerId}, '\"]')) AND DATE(FROM_UNIXTIME(msgtime/1000)) = CURDATE()") Integer countTodayMsgByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId); @Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND from_user = #{userId}") Long selectLastMsgTimeByUser(@Param("userId") String userId, @Param("corpId") String corpId); - @Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") + @Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId} " + + "OR to_user = CONCAT('[\"', #{userId}, '\"]'))") Long selectLastMsgTimeByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); - @Select("SELECT MIN(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") + @Select("SELECT MIN(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId} " + + "OR to_user = CONCAT('[\"', #{userId}, '\"]'))") Long selectFirstMsgTimeByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); - @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})") + @Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId} " + + "OR to_user = CONCAT('[\"', #{userId}, '\"]'))") Integer countMsgByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId); @Select("SELECT DISTINCT u.staff_id FROM (" + diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java index d1b8020..b90680a 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java @@ -412,8 +412,17 @@ public class ArchiveSessionService { vo.setStaffName(s != null && s.getName() != null ? s.getName() : toUser); } - // 最后一条消息内容 - ArchiveMessage latest = archiveMessageMapper.selectBySessionId(sessionId, corpId, 0, 1).get(0); + // 最后一条消息内容(取 msgtime 最大的一条,兼容反向 session_id 的历史数据) + ArchiveMessage latest = archiveMessageMapper.selectLatestBySessionId(sessionId, corpId); + String reverseId = reverseSessionId(sessionId); + if (reverseId != null && !reverseId.equals(sessionId)) { + ArchiveMessage reverseLatest = archiveMessageMapper.selectLatestBySessionId(reverseId, corpId); + if (reverseLatest != null && (latest == null + || (reverseLatest.getMsgtime() != null + && (latest.getMsgtime() == null || reverseLatest.getMsgtime() > latest.getMsgtime())))) { + latest = reverseLatest; + } + } if (latest != null) { vo.setLastMsgContent(latest.getContent()); } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java index eef7fbb..94fea75 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/DailySummaryService.java @@ -111,6 +111,24 @@ public class DailySummaryService { return queryByExpr(customerId, expr, false); } + /** + * 查询客户所有日期的消息总结(不按员工过滤,供运营按联系方式查询) + * + * @param customerId 客户企微外部联系人 userid + * @return 总结列表,按 msg_time 倒序 + */ + public List queryAllDailySummaryByCustomer(String customerId) { + if (customerId == null || customerId.trim().isEmpty()) { + throw new IllegalArgumentException("customerId 不能为空"); + } + + String c = escape(customerId); + // 客户发(to_user 可能为单值或 JSON 数组),或客户收 + String expr = String.format("from_user == '%s' || to_user like '%%%s%%'", c, c); + + return queryByExpr(customerId, expr, false); + } + private List queryByExpr(String customerId, String expr, boolean asc) { log.info("Query Milvus daily summary, customerId={}, expr={}", customerId, expr); diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerDailySummaryVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerDailySummaryVO.java new file mode 100644 index 0000000..b32f4ce --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerDailySummaryVO.java @@ -0,0 +1,25 @@ +package com.artedu.archive.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 按联系方式查询到的客户每日总结分组 VO + */ +@Data +public class CustomerDailySummaryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private String customerId; + private String customerName; + private String phone; + private String wechatId; + + /** + * 该客户的每日总结列表,按日期倒序 + */ + private List list; +} diff --git a/fix_customer_names_server.py b/fix_customer_names_server.py new file mode 100644 index 0000000..d444b1e --- /dev/null +++ b/fix_customer_names_server.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# 批量修复 customers 表中 name = customer_id 的客户昵称(企微 externalcontact/get 回填,多线程版) +# 在服务器上以 nohup 运行:nohup python3 /tmp/fix_customer_names.py > /tmp/fix_customer_names.log 2>&1 & +import json +import subprocess +import threading +import time +import urllib.request +import urllib.parse +from concurrent.futures import ThreadPoolExecutor + +DB_HOST = 'sh-cdb-6fzlwnms.sql.tencentcdb.com' +DB_PORT = '63912' +DB_USER = 'assistant_prod' +DB_PASS = 'U%$4Tu_C3+4' +DB_NAME = 'ai_assistant' +CORPID = 'wwd483c2fba24ae30a' +SECRET = 'nU1xezyHyonS9rOJ9Kwo3i5OM9iuWc1TrjXf-rOYZx0' +WORKERS = 6 + +MYSQL = ['docker', 'exec', '-i', 'ai-assistant-mysql', 'mysql', + '-h', DB_HOST, '-P', DB_PORT, '-u', DB_USER, '-p' + DB_PASS, + '--default-character-set=utf8mb4', DB_NAME] + +lock = threading.Lock() +state = {'token': '', 'fixed': 0, 'failed': 0, 'done': 0, 'buf': []} + + +def http_json(url): + return json.loads(urllib.request.urlopen(url, timeout=15).read().decode('utf-8')) + + +def get_token(): + d = http_json('https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s' % (CORPID, SECRET)) + return d.get('access_token', '') + + +def sql_escape(s): + return s.replace('\\', '\\\\').replace("'", "\\'") + + +def flush_buf(): + with lock: + if not state['buf']: + return + sql = '\n'.join(state['buf']) + '\n' + state['buf'] = [] + r = subprocess.run(MYSQL, input=sql.encode('utf-8'), + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if r.returncode != 0: + print('SQL apply error: %s' % r.stderr.decode('utf-8', 'ignore')[:200], flush=True) + + +def process(cid, total): + for attempt in range(3): + try: + with lock: + token = state['token'] + url = ('https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get' + '?access_token=%s&external_userid=%s' % (token, urllib.parse.quote(cid))) + d = http_json(url) + ec = d.get('errcode') + if ec == 0: + c = d.get('external_contact') or {} + name = (c.get('name') or '').strip() + if name: + sets = ["name='%s'" % sql_escape(name)] + av = (c.get('avatar') or '').strip() + if av: + sets.append("avatar='%s'" % sql_escape(av)) + stmt = "UPDATE customers SET %s WHERE customer_id='%s';" % (', '.join(sets), sql_escape(cid)) + with lock: + state['buf'].append(stmt) + state['fixed'] += 1 + n = len(state['buf']) + if n >= 200: + flush_buf() + else: + with lock: + state['failed'] += 1 + break + elif ec in (40014, 42001): + with lock: + state['token'] = get_token() + time.sleep(0.5) + elif ec == 45009: + time.sleep(2) + else: + with lock: + state['failed'] += 1 + break + except Exception as e: + if attempt == 2: + with lock: + state['failed'] += 1 + print('error %s: %s' % (cid, str(e)[:100]), flush=True) + time.sleep(1) + time.sleep(0.03) + with lock: + state['done'] += 1 + done = state['done'] + fixed = state['fixed'] + failed = state['failed'] + if done % 500 == 0: + print('progress %d/%d fixed=%d failed=%d' % (done, total, fixed, failed), flush=True) + + +def main(): + q = subprocess.run(['docker', 'exec', '-i', 'ai-assistant-mysql', 'mysql', + '-h', DB_HOST, '-P', DB_PORT, '-u', DB_USER, '-p' + DB_PASS, + '-N', '--default-character-set=utf8mb4', DB_NAME, + '-e', "SELECT customer_id FROM customers WHERE name = customer_id OR name IS NULL OR name = ''"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + cids = [l.strip() for l in q.stdout.decode('utf-8', 'ignore').splitlines() + if l.strip().startswith(('wm', 'wo'))] + total = len(cids) + print('candidates: %d' % total, flush=True) + + state['token'] = get_token() + if not state['token']: + print('FATAL: no access token', flush=True) + return + print('token acquired, workers=%d' % WORKERS, flush=True) + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + for cid in cids: + pool.submit(process, cid, total) + + flush_buf() + print('DONE fixed=%d failed=%d' % (state['fixed'], state['failed']), flush=True) + + +main() diff --git a/frontend/sidebar/nginx.conf b/frontend/sidebar/nginx.conf index 50602f7..54d1f98 100644 --- a/frontend/sidebar/nginx.conf +++ b/frontend/sidebar/nginx.conf @@ -13,10 +13,15 @@ server { } # 每日总结独立页面 - location /summary { + location = /summary { try_files /summary.html =404; } + # 运营按手机号/微信号查询客户总结 + location = /summary-search { + try_files /summary-search.html =404; + } + # 静态资源 location / { try_files $uri $uri/ /index.html; diff --git a/frontend/sidebar/src/SummaryApp.tsx b/frontend/sidebar/src/SummaryApp.tsx index 32ef826..2bda35b 100644 --- a/frontend/sidebar/src/SummaryApp.tsx +++ b/frontend/sidebar/src/SummaryApp.tsx @@ -1,40 +1,24 @@ import { useEffect, useState } from 'react' -import { SpinLoading, Empty, Toast } from 'antd-mobile' +import { SpinLoading, Empty, Toast, Popup, Input, Button } from 'antd-mobile' +import { SummaryCard, SummaryItem } from './components/SummaryCard' +import { ChatMessageList, ChatMessage } from './components/ChatMessageList' +import { noCacheFetch } from './services/http' const CORPID = 'wwd483c2fba24ae30a' const AGENTID = '1000021' const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html') -interface SummaryItem { - id?: number - msgTime?: string - msgType?: number - fromUser?: string - toUser?: string - roomId?: string - messageContext?: string - summary?: string - createdAt?: number - staffId?: string - staffName?: string -} - -interface SummarySection { - title: string - items: string[] -} - -interface ParsedSummary { - sections: SummarySection[] - tags: string[] - raw: string -} - function SummaryApp() { const [loading, setLoading] = useState(true) const [list, setList] = useState([]) const [error, setError] = useState('') const [needLogin, setNeedLogin] = useState(false) + const [customerId, setCustomerId] = useState('') + const [chatMessages, setChatMessages] = useState([]) + const [bindVisible, setBindVisible] = useState(false) + const [bindPhone, setBindPhone] = useState('') + const [bindWechatId, setBindWechatId] = useState('') + const [bindSaving, setBindSaving] = useState(false) useEffect(() => { const init = async () => { @@ -70,19 +54,6 @@ function SummaryApp() { init() }, []) - const noCacheFetch = async (url: string, options?: RequestInit) => { - const sep = url.includes('?') ? '&' : '?' - const nocacheUrl = `${url}${sep}_t=${Date.now()}` - return fetch(nocacheUrl, { - ...options, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - ...options?.headers - } - }) - } - const tryCodeLogin = async (code: string): Promise => { try { const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' }) @@ -211,6 +182,7 @@ function SummaryApp() { Toast.show({ content: '缺少客户ID参数', icon: 'fail' }) return } + setCustomerId(customerId) try { const res = await fetch( @@ -225,6 +197,10 @@ function SummaryApp() { const data = await res.json() if (data.code === 0 && Array.isArray(data.data)) { setList(data.data) + // 新客户当天还没有每日总结时,兜底展示最近 10 条聊天记录 + if (data.data.length === 0) { + loadRecentChat(customerId, staffId) + } } else { const msg = data.message || '加载失败' setError(msg) @@ -239,6 +215,68 @@ function SummaryApp() { } } + const loadRecentChat = async (customerId: string, staffId: string) => { + try { + const res = await noCacheFetch( + `/api/v1/archive/messages/recent-chat?customerId=${encodeURIComponent(customerId)}&staffId=${encodeURIComponent(staffId)}&limit=10` + ) + const data = await res.json() + if (data.code === 0 && Array.isArray(data.data)) { + setChatMessages(data.data) + } + } catch (e) { + console.error('加载最近聊天记录失败:', e) + } + } + + const openBindPopup = async () => { + setBindVisible(true) + try { + const res = await noCacheFetch(`/api/v1/archive/customer/contact?customerId=${encodeURIComponent(customerId)}`) + const data = await res.json() + if (data.code === 0 && data.data) { + setBindPhone(data.data.phone || '') + setBindWechatId(data.data.wechatId || '') + } + } catch (e) { + console.error('加载联系方式失败:', e) + } + } + + const saveBindContact = async () => { + const phone = bindPhone.trim() + const wechatId = bindWechatId.trim() + if (!phone && !wechatId) { + Toast.show({ content: '手机号、微信号至少填写一项', icon: 'fail' }) + return + } + if (phone && !/^1\d{10}$/.test(phone)) { + Toast.show({ content: '手机号格式不正确', icon: 'fail' }) + return + } + + setBindSaving(true) + try { + const res = await noCacheFetch('/api/v1/archive/customer/bind-contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customerId, phone, wechatId }) + }) + const data = await res.json() + if (data.code === 0) { + Toast.show({ content: '绑定成功', icon: 'success' }) + setBindVisible(false) + } else { + Toast.show({ content: data.message || '绑定失败', icon: 'fail' }) + } + } catch (e) { + console.error('绑定联系方式失败:', e) + Toast.show({ content: '网络错误', icon: 'fail' }) + } finally { + setBindSaving(false) + } + } + if (loading) { return (
@@ -279,14 +317,6 @@ function SummaryApp() { ) } - if (list.length === 0) { - return ( -
- -
- ) - } - return (
-
- 📋 每日总结 -
-
- 共 {list.length} 条 · 按时间倒序 -
-
- - {list.map((item, index) => ( - - ))} -
- ) -} - -function SummaryCard({ item }: { item: SummaryItem }) { - const parsed = parseSummary(item.summary) - const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0 - - return ( -
- {/* 头部 */} -
-
- - - {item.msgTime || formatDate(item.createdAt)} - -
-
- - - {item.staffName || item.staffId || '未知老师'} - -
-
- - {hasStructure ? ( - <> - {parsed.sections.map((section, idx) => ( - - ))} - {parsed.tags.length > 0 && } - - ) : ( -
- {item.summary || item.messageContext || '无内容'} -
- )} -
- ) -} - -function SectionBlock({ section }: { section: SummarySection }) { - const config = getSectionConfig(section.title) - const showTitle = displaySectionTitle(section.title) - - return ( -
-
- {config.icon} - {showTitle} -
-
- {section.items.map((text, idx) => ( -
-
- {section.title.includes('核心诉求') ? ( - - ) : section.title.includes('处理结果') ? ( - - ) : section.title.includes('待办') ? ( - - ) : ( - - )} -
-
- {text} -
+
+
+ 📋 每日总结
- ))} -
-
- ) -} - -function TagList({ tags }: { tags: string[] }) { - return ( -
-
- 🏷️ - 关键标签 -
-
- {tags.map((tag, idx) => ( - + 共 {list.length} 条 · 按时间倒序 +
+
+
+ + {list.length === 0 ? ( + chatMessages.length > 0 ? ( + <> +
+
+ 💬 最近聊天记录 +
+
+ 暂无每日总结,展示最近 {chatMessages.length} 条聊天 +
+
+ + + ) : ( + + ) + ) : ( + list.map((item, index) => ( + + )) + )} + + setBindVisible(false)} + position="bottom" + bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '20px 16px 32px' }} + > +
+ 绑定联系方式 +
+
+ 绑定后运营可通过手机号或微信号查询该学员的每日总结 +
+ +
+
手机号
+ +
+ +
+
微信号
+ +
+ + +
) } -function getSectionConfig(title: string) { - if (title.includes('核心诉求')) { - return { icon: '📝', titleColor: '#1677ff' } - } - if (title.includes('处理结果')) { - return { icon: '✅', titleColor: '#52c41a' } - } - if (title.includes('待办') || title.includes('后续')) { - return { icon: '⏰', titleColor: '#fa8c16' } - } - return { icon: '•', titleColor: '#666' } -} - -function displaySectionTitle(title: string) { - if (title.includes('核心诉求')) return '学员咨询' - if (title.includes('处理结果')) return '老师解答' - if (title.includes('待办')) return '跟进建议' - return title -} - -function NumberBadge({ n }: { n: number }) { - return ( -
- {n} -
- ) -} - -function GreenCheck() { - return ( -
- ✓ -
- ) -} - -function OrangeClock() { - return ( -
- 🕐 -
- ) -} - -function Dot({ color }: { color: string }) { - return ( -
- ) -} - -function CalendarIcon() { - return ( - - - - - - - ) -} - -function UserIcon() { - return ( - - - - - ) -} - -function parseSummary(text?: string): ParsedSummary { - if (!text) return { sections: [], tags: [], raw: '' } - - const result: ParsedSummary = { sections: [], tags: [], raw: text } - let content = text - - // 提取关键标签:支持 "**关键标签**:#a#b" 或 "关键标签:#a#b" 或单独行 - const tagPatterns = [ - /(?:\*\*)?关键标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s, - /(?:\*\*)?标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s - ] - for (const pattern of tagPatterns) { - const tagMatch = content.match(pattern) - if (tagMatch) { - result.tags = parseTags(tagMatch[1]) - content = content.replace(tagMatch[0], '') - break - } - } - - // 按 **标题** 分隔区块,标题可能有空格 - const sectionRegex = /\*\*\s*(核心诉求|处理结果|待办事项|待处理|后续跟进|关键标签)\s*\*\*/g - const parts: { title: string; text: string }[] = [] - let lastIndex = 0 - let lastTitle = '' - let match - - while ((match = sectionRegex.exec(content)) !== null) { - if (lastTitle) { - parts.push({ title: lastTitle, text: content.slice(lastIndex, match.index) }) - } - lastTitle = match[1] - lastIndex = match.index + match[0].length - } - if (lastTitle) { - parts.push({ title: lastTitle, text: content.slice(lastIndex) }) - } - - for (const part of parts) { - if (part.title === '关键标签') { - const tags = parseTags(part.text) - if (tags.length) result.tags = tags - continue - } - const items = parseItems(part.text) - if (items.length > 0) { - result.sections.push({ title: part.title, items }) - } - } - - // 没有任何结构化区块时,尝试整段按行解析列表 - if (result.sections.length === 0 && result.tags.length === 0) { - const items = parseItems(content) - if (items.length > 1) { - result.sections.push({ title: '总结', items }) - } - } - - return result -} - -function parseItems(text: string): string[] { - const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0) - const items: string[] = [] - - for (const line of lines) { - // 匹配 1. / 1. / - / * / • 开头的列表项 - const match = line.match(/^(?:\d+[..]\s*|[\-*•]\s+)(.+)$/) - if (match) { - items.push(cleanText(match[1])) - } else if (items.length > 0 && !line.match(/^\*\*/)) { - // 续行(非标题) - items[items.length - 1] += ' ' + cleanText(line) - } - } - - // 如果整段没有列表项,把段落本身作为一条内容(兼容纯文本区块) - if (items.length === 0 && text.trim().length > 0) { - items.push(cleanText(text.trim())) - } - - return items -} - -function parseTags(text: string): string[] { - const s = text.trim() - if (!s) return [] - // 支持 #标签1#标签2 或 标签1#标签2 或 #标签1 #标签2 - return s.split(/[#\s]+/) - .map(t => t.trim()) - .filter(t => t.length > 0 && t !== '、' && t !== ',') -} - -function cleanText(s: string): string { - return s.replace(/\*\*/g, '').trim() -} - -function formatDate(timestamp?: number) { - if (!timestamp) return '-' - const date = new Date(timestamp) - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` -} - export default SummaryApp diff --git a/frontend/sidebar/src/SummarySearchApp.tsx b/frontend/sidebar/src/SummarySearchApp.tsx new file mode 100644 index 0000000..1f48e89 --- /dev/null +++ b/frontend/sidebar/src/SummarySearchApp.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from 'react' +import { SpinLoading, Empty, Toast, SearchBar } from 'antd-mobile' +import { SummaryCard, SummaryItem } from './components/SummaryCard' +import { ChatMessageList, ChatMessage } from './components/ChatMessageList' +import { noCacheFetch } from './services/http' + +const CORPID = 'wwd483c2fba24ae30a' +const AGENTID = '1000021' +const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary-search.html') + +interface CustomerSummaryGroup { + customerId?: string + customerName?: string + phone?: string + wechatId?: string + list?: SummaryItem[] +} + +function SummarySearchApp() { + const [loading, setLoading] = useState(true) + const [searching, setSearching] = useState(false) + const [keyword, setKeyword] = useState('') + const [groups, setGroups] = useState(null) + const [needLogin, setNeedLogin] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + const init = async () => { + const urlParams = new URLSearchParams(window.location.search) + const code = urlParams.get('code') + + // 1. URL 中有 code → 先登录 + if (code) { + const cleanUrl = window.location.pathname + window.location.hash + window.history.replaceState({}, document.title, cleanUrl) + const ok = await tryCodeLogin(code) + if (!ok) { + setLoading(false) + setNeedLogin(true) + setError('登录失败,请重试') + return + } + } + + // 2. 检查员工登录态 + const staffId = urlParams.get('staffId') || localStorage.getItem('staff_user_id') || '' + if (!staffId) { + setLoading(false) + setNeedLogin(true) + setError('未获取到员工ID,请先登录') + return + } + + setLoading(false) + } + + init() + }, []) + + const tryCodeLogin = async (code: string): Promise => { + try { + const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' }) + const data = await res.json() + if (data.code === 0 && data.data) { + const info = data.data + localStorage.setItem('staff_token', info.token) + localStorage.setItem('staff_user_id', info.userId) + localStorage.setItem('staff_user_name', info.userName || '') + localStorage.setItem('staff_avatar', info.avatar || '') + localStorage.setItem('staff_role', info.role || 'SEAT') + console.log('OAuth 登录成功:', info.userId, info.userName) + return true + } + console.error('OAuth login failed:', data.message) + } catch (e) { + console.error('Code login error:', e) + } + return false + } + + const redirectToWeComOAuth = () => { + const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORPID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&agentid=${AGENTID}&state=summarySearch#wechat_redirect` + console.log('跳转企微 OAuth:', oauthUrl) + window.location.href = oauthUrl + } + + const doSearch = async () => { + const kw = keyword.trim() + if (!kw) { + Toast.show({ content: '请输入手机号或微信号', icon: 'fail' }) + return + } + + setSearching(true) + try { + const res = await noCacheFetch(`/api/v1/archive/daily-summary/by-contact?keyword=${encodeURIComponent(kw)}`) + const data = await res.json() + if (data.code === 0 && Array.isArray(data.data)) { + setGroups(data.data) + if (data.data.length === 0) { + Toast.show({ content: '未找到绑定该联系方式的客户', icon: 'fail' }) + } + } else { + const msg = data.message || '查询失败' + Toast.show({ content: msg, icon: 'fail' }) + } + } catch (e) { + console.error('查询每日总结失败:', e) + Toast.show({ content: '网络错误', icon: 'fail' }) + } finally { + setSearching(false) + } + } + + if (loading) { + return ( +
+ +
加载中...
+
+ ) + } + + if (needLogin) { + return ( +
+
{error || '未获取到员工ID,请先登录'}
+ +
+ ) + } + + const totalCount = (groups || []).reduce((sum, g) => sum + (g.list?.length || 0), 0) + + // 昵称缺失(仍是企微外部联系人ID)时,显示 ID 尾号而不是整串 ID + const displayName = (g: CustomerSummaryGroup) => { + const name = (g.customerName || '').trim() + if (name && name !== g.customerId) return name + const id = g.customerId || '' + return id ? `客户 …${id.slice(-6)}` : '未命名客户' + } + + return ( +
+
+
+ 🔍 客户总结查询 +
+ false} + style={{ '--border-radius': '8px', padding: 0 } as any} + /> + + {groups !== null && groups.length > 0 && ( +
+ 共 {groups.length} 个客户 · {totalCount} 条总结 +
+ )} +
+ + {groups !== null && groups.length === 0 && ( + + )} + + {(groups || []).map((group, gi) => ( +
+
+
+ 👤 {displayName(group)} +
+
+ {group.phone &&
📱 {group.phone}
} + {group.wechatId &&
💬 {group.wechatId}
} +
+
+ + {(group.list || []).length === 0 ? ( + + ) : ( + (group.list || []).map((item, index) => ( + + )) + )} +
+ ))} +
+ ) +} + +export default SummarySearchApp + +/** + * 客户暂无每日总结时的兜底:加载最近 10 条单聊消息 + */ +function RecentChatFallback({ customerId }: { customerId: string }) { + const [chatMessages, setChatMessages] = useState(null) + + useEffect(() => { + if (!customerId) { + setChatMessages([]) + return + } + const load = async () => { + try { + const res = await noCacheFetch( + `/api/v1/archive/messages/recent-chat?customerId=${encodeURIComponent(customerId)}&limit=10` + ) + const data = await res.json() + setChatMessages(data.code === 0 && Array.isArray(data.data) ? data.data : []) + } catch (e) { + console.error('加载最近聊天记录失败:', e) + setChatMessages([]) + } + } + load() + }, [customerId]) + + if (chatMessages === null) { + return ( +
+ +
+ ) + } + + if (chatMessages.length === 0) { + return + } + + return ( + <> +
+ 暂无每日总结,展示最近 {chatMessages.length} 条聊天 +
+ + + ) +} diff --git a/frontend/sidebar/src/components/ChatMessageList.tsx b/frontend/sidebar/src/components/ChatMessageList.tsx new file mode 100644 index 0000000..e41514b --- /dev/null +++ b/frontend/sidebar/src/components/ChatMessageList.tsx @@ -0,0 +1,140 @@ +/** + * 单聊消息气泡列表(微信风格,summary / summary-search 页面共用) + * 老师在右侧(绿气泡),客户在左侧(白气泡),显示发送人昵称和消息类型 + */ + +export interface ChatMessage { + id?: number + msgid?: string + seq?: number + fromUser?: string + fromUserName?: string + fromRole?: string + toUser?: string + toUserName?: string + msgtype?: string + msgtime?: number + msgTimeStr?: string + content?: string +} + +const MSG_TYPE_LABELS: Record = { + text: '文本', + image: '图片', + emotion: '表情', + voice: '语音', + video: '视频', + file: '文件', + link: '链接', + weapp: '小程序', + location: '位置', + card: '名片', + mixed: '混合消息', + meeting: '会议', + docmsg: '文档', + markdown: 'Markdown' +} + +function typeLabel(msgtype?: string) { + return MSG_TYPE_LABELS[msgtype || ''] || msgtype || '消息' +} + +/** 文本消息显示内容,非文本消息显示类型占位 */ +function displayContent(msg: ChatMessage) { + const text = (msg.content || '').trim() + if (text) return text + return `[${typeLabel(msg.msgtype)}]` +} + +/** 发送人显示名:缺失或仍为企微ID时用尾号 */ +function senderName(msg: ChatMessage) { + const name = (msg.fromUserName || '').trim() + if (name && name !== msg.fromUser) return name + const id = msg.fromUser || '' + return id ? `…${id.slice(-6)}` : '未知' +} + +export function ChatMessageList({ messages }: { messages: ChatMessage[] }) { + // 接口返回倒序(最新在前),聊天视图按时间正序展示 + const ordered = [...messages].reverse() + + return ( +
+ {ordered.map((msg, idx) => { + const isStaff = msg.fromRole === 'INTERNAL' + const name = senderName(msg) + return ( +
+ {/* 头像:昵称首字 */} +
+ {name.slice(0, 1)} +
+ +
+ {/* 气泡 */} +
+ {displayContent(msg)} +
+ {/* 昵称 + 消息类型 */} +
+ {name} + + {typeLabel(msg.msgtype)} + + {msg.msgTimeStr && {msg.msgTimeStr.slice(5, 16)}} +
+
+
+ ) + })} +
+ ) +} diff --git a/frontend/sidebar/src/components/SummaryCard.tsx b/frontend/sidebar/src/components/SummaryCard.tsx new file mode 100644 index 0000000..c1c7c32 --- /dev/null +++ b/frontend/sidebar/src/components/SummaryCard.tsx @@ -0,0 +1,374 @@ +/** + * 每日总结卡片渲染组件(summary / summary-search 页面共用) + */ + +export interface SummaryItem { + id?: number + msgTime?: string + msgType?: number + fromUser?: string + toUser?: string + roomId?: string + messageContext?: string + summary?: string + createdAt?: number + staffId?: string + staffName?: string +} + +interface SummarySection { + title: string + items: string[] +} + +interface ParsedSummary { + sections: SummarySection[] + tags: string[] + raw: string +} + +export function SummaryCard({ item }: { item: SummaryItem }) { + const parsed = parseSummary(item.summary) + const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0 + + return ( +
+ {/* 头部 */} +
+
+ + + {item.msgTime || formatDate(item.createdAt)} + +
+
+ + + {item.staffName || item.staffId || '未知老师'} + +
+
+ + {hasStructure ? ( + <> + {parsed.sections.map((section, idx) => ( + + ))} + {parsed.tags.length > 0 && } + + ) : ( +
+ {item.summary || item.messageContext || '无内容'} +
+ )} +
+ ) +} + +function SectionBlock({ section }: { section: SummarySection }) { + const config = getSectionConfig(section.title) + const showTitle = displaySectionTitle(section.title) + + return ( +
+
+ {config.icon} + {showTitle} +
+
+ {section.items.map((text, idx) => ( +
+
+ {section.title.includes('核心诉求') ? ( + + ) : section.title.includes('处理结果') ? ( + + ) : section.title.includes('待办') ? ( + + ) : ( + + )} +
+
+ {text} +
+
+ ))} +
+
+ ) +} + +function TagList({ tags }: { tags: string[] }) { + return ( +
+
+ 🏷️ + 关键标签 +
+
+ {tags.map((tag, idx) => ( + + #{tag} + + ))} +
+
+ ) +} + +function getSectionConfig(title: string) { + if (title.includes('核心诉求')) { + return { icon: '📝', titleColor: '#1677ff' } + } + if (title.includes('处理结果')) { + return { icon: '✅', titleColor: '#52c41a' } + } + if (title.includes('待办') || title.includes('后续')) { + return { icon: '⏰', titleColor: '#fa8c16' } + } + return { icon: '•', titleColor: '#666' } +} + +function displaySectionTitle(title: string) { + if (title.includes('核心诉求')) return '学员咨询' + if (title.includes('处理结果')) return '老师解答' + if (title.includes('待办')) return '跟进建议' + return title +} + +function NumberBadge({ n }: { n: number }) { + return ( +
+ {n} +
+ ) +} + +function GreenCheck() { + return ( +
+ ✓ +
+ ) +} + +function OrangeClock() { + return ( +
+ 🕐 +
+ ) +} + +function Dot({ color }: { color: string }) { + return ( +
+ ) +} + +function CalendarIcon() { + return ( + + + + + + + ) +} + +function UserIcon() { + return ( + + + + + ) +} + +function parseSummary(text?: string): ParsedSummary { + if (!text) return { sections: [], tags: [], raw: '' } + + const result: ParsedSummary = { sections: [], tags: [], raw: text } + let content = text + + // 提取关键标签:支持 "**关键标签**:#a#b" 或 "关键标签:#a#b" 或单独行 + const tagPatterns = [ + /(?:\*\*)?关键标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s, + /(?:\*\*)?标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s + ] + for (const pattern of tagPatterns) { + const tagMatch = content.match(pattern) + if (tagMatch) { + result.tags = parseTags(tagMatch[1]) + content = content.replace(tagMatch[0], '') + break + } + } + + // 按 **标题** 分隔区块,标题可能有空格 + const sectionRegex = /\*\*\s*(核心诉求|处理结果|待办事项|待处理|后续跟进|关键标签)\s*\*\*/g + const parts: { title: string; text: string }[] = [] + let lastIndex = 0 + let lastTitle = '' + let match + + while ((match = sectionRegex.exec(content)) !== null) { + if (lastTitle) { + parts.push({ title: lastTitle, text: content.slice(lastIndex, match.index) }) + } + lastTitle = match[1] + lastIndex = match.index + match[0].length + } + if (lastTitle) { + parts.push({ title: lastTitle, text: content.slice(lastIndex) }) + } + + for (const part of parts) { + if (part.title === '关键标签') { + const tags = parseTags(part.text) + if (tags.length) result.tags = tags + continue + } + const items = parseItems(part.text) + if (items.length > 0) { + result.sections.push({ title: part.title, items }) + } + } + + // 没有任何结构化区块时,尝试整段按行解析列表 + if (result.sections.length === 0 && result.tags.length === 0) { + const items = parseItems(content) + if (items.length > 1) { + result.sections.push({ title: '总结', items }) + } + } + + return result +} + +function parseItems(text: string): string[] { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0) + const items: string[] = [] + + for (const line of lines) { + // 匹配 1. / 1. / - / * / • 开头的列表项 + const match = line.match(/^(?:\d+[..]\s*|[\-*•]\s+)(.+)$/) + if (match) { + items.push(cleanText(match[1])) + } else if (items.length > 0 && !line.match(/^\*\*/)) { + // 续行(非标题) + items[items.length - 1] += ' ' + cleanText(line) + } + } + + // 如果整段没有列表项,把段落本身作为一条内容(兼容纯文本区块) + if (items.length === 0 && text.trim().length > 0) { + items.push(cleanText(text.trim())) + } + + return items +} + +function parseTags(text: string): string[] { + const s = text.trim() + if (!s) return [] + // 支持 #标签1#标签2 或 标签1#标签2 或 #标签1 #标签2 + return s.split(/[#\s]+/) + .map(t => t.trim()) + .filter(t => t.length > 0 && t !== '、' && t !== ',') +} + +function cleanText(s: string): string { + return s.replace(/\*\*/g, '').trim() +} + +function formatDate(timestamp?: number) { + if (!timestamp) return '-' + const date = new Date(timestamp) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} diff --git a/frontend/sidebar/src/services/http.ts b/frontend/sidebar/src/services/http.ts new file mode 100644 index 0000000..9b6aa77 --- /dev/null +++ b/frontend/sidebar/src/services/http.ts @@ -0,0 +1,15 @@ +/** + * 防缓存 fetch:URL 追加 _t 时间戳,并带上 no-cache 请求头 + */ +export async function noCacheFetch(url: string, options?: RequestInit): Promise { + const sep = url.includes('?') ? '&' : '?' + const nocacheUrl = `${url}${sep}_t=${Date.now()}` + return fetch(nocacheUrl, { + ...options, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + ...options?.headers + } + }) +} diff --git a/frontend/sidebar/src/summary-search.tsx b/frontend/sidebar/src/summary-search.tsx new file mode 100644 index 0000000..f9a5f83 --- /dev/null +++ b/frontend/sidebar/src/summary-search.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import SummarySearchApp from './SummarySearchApp' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/sidebar/summary-search.html b/frontend/sidebar/summary-search.html new file mode 100644 index 0000000..17aa1d3 --- /dev/null +++ b/frontend/sidebar/summary-search.html @@ -0,0 +1,16 @@ + + + + + + + + + 客户总结查询 + + + +
+ + + diff --git a/frontend/sidebar/vite.config.ts b/frontend/sidebar/vite.config.ts index 7d645b4..8da2c7b 100644 --- a/frontend/sidebar/vite.config.ts +++ b/frontend/sidebar/vite.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'index.html'), - summary: resolve(__dirname, 'summary.html') + summary: resolve(__dirname, 'summary.html'), + 'summary-search': resolve(__dirname, 'summary-search.html') } } } diff --git a/init.sql b/init.sql index 1a75398..179d9aa 100644 --- a/init.sql +++ b/init.sql @@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `customers` ( `type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业', `gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女', `phone` VARCHAR(32) COMMENT '手机号', + `wechat_id` VARCHAR(64) COMMENT '微信号', `unionid` VARCHAR(128) COMMENT '微信unionid', `add_time` TIMESTAMP NULL COMMENT '添加时间', `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除',