feat: 新增运营端客户总结查询功能,优化现有总结页面
1. 新增客户联系方式查询客户每日总结的完整功能,包括前端页面、后端接口和数据库字段支持 2. 优化原有每日总结页面,新增绑定客户联系方式功能和无总结时的聊天记录兜底展示 3. 修复消息查询时的to_user数组匹配问题,新增客户微信号存储字段 4. 补充批量修复客户昵称的脚本和部署文档更新
This commit is contained in:
parent
70c6f1c789
commit
6a13b47328
@ -48,11 +48,15 @@ cat > index.html << HTML
|
|||||||
HTML
|
HTML
|
||||||
# 复制到容器并重启
|
# 复制到容器并重启
|
||||||
docker cp . ai-assistant-sidebar:/usr/share/nginx/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
|
docker restart ai-assistant-nginx
|
||||||
EOF
|
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 部署
|
## 二、后台 Admin 部署
|
||||||
@ -136,3 +140,4 @@ EOF
|
|||||||
- **前端站点**: `https://ai.9artedu.com/sidebar/`
|
- **前端站点**: `https://ai.9artedu.com/sidebar/`
|
||||||
- **后台站点**: `https://ai.9artedu.com/admin/`
|
- **后台站点**: `https://ai.9artedu.com/admin/`
|
||||||
- **代理**: 宝塔 Nginx → Docker Nginx (8089) → 各容器
|
- **代理**: 宝塔 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地址> ...` 执行。
|
||||||
|
|||||||
@ -368,6 +368,60 @@ public class ArchiveMessageController {
|
|||||||
return userId != null && (userId.startsWith("wm") || userId.startsWith("wo"));
|
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<List<ArchiveMessageVO>> 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<ArchiveMessage> 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<ArchiveMessage> messages = archiveMessageMapper.selectList(wrapper);
|
||||||
|
List<ArchiveMessageVO> list = messages.stream().map(this::toVO).collect(Collectors.toList());
|
||||||
|
// 补充发送人/接收人昵称(客户昵称、销售老师名称)
|
||||||
|
enrichSenderNames(list);
|
||||||
|
return Result.success(list);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息详情
|
* 获取消息详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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<Map<String, String>> getContact(@RequestParam String customerId) {
|
||||||
|
Customer customer = customerMapper.selectByCustomerId(customerId, corpId);
|
||||||
|
if (customer == null) {
|
||||||
|
return Result.fail("客户不存在");
|
||||||
|
}
|
||||||
|
Map<String, String> 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<String> bindContact(@RequestBody Map<String, String> 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<Customer> 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("绑定成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,21 @@
|
|||||||
package com.artedu.archive.controller;
|
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.service.DailySummaryService;
|
||||||
|
import com.artedu.archive.vo.CustomerDailySummaryVO;
|
||||||
import com.artedu.archive.vo.DailySummaryVO;
|
import com.artedu.archive.vo.DailySummaryVO;
|
||||||
import com.artedu.common.result.Result;
|
import com.artedu.common.result.Result;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,6 +29,12 @@ public class DailySummaryController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DailySummaryService dailySummaryService;
|
private DailySummaryService dailySummaryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询客户指定日期的消息总结
|
* 查询客户指定日期的消息总结
|
||||||
*
|
*
|
||||||
@ -51,4 +63,40 @@ public class DailySummaryController {
|
|||||||
List<DailySummaryVO> list = dailySummaryService.queryAllDailySummary(customerId, staffId);
|
List<DailySummaryVO> list = dailySummaryService.queryAllDailySummary(customerId, staffId);
|
||||||
return Result.success(list);
|
return Result.success(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按手机号/微信号查询客户的所有每日总结(不按员工过滤,供运营使用)
|
||||||
|
*
|
||||||
|
* @param keyword 手机号或微信号,精确匹配 customers.phone / customers.wechat_id
|
||||||
|
* @return 按客户分组的总结列表,无匹配时返回空列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/daily-summary/by-contact")
|
||||||
|
public Result<List<CustomerDailySummaryVO>> dailySummaryByContact(@RequestParam String keyword) {
|
||||||
|
if (keyword == null || keyword.trim().isEmpty()) {
|
||||||
|
return Result.fail("keyword 不能为空");
|
||||||
|
}
|
||||||
|
String kw = keyword.trim();
|
||||||
|
|
||||||
|
LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(Customer::getCorpId, corpId)
|
||||||
|
.and(w -> w.eq(Customer::getPhone, kw).or().eq(Customer::getWechatId, kw));
|
||||||
|
List<Customer> customers = customerMapper.selectList(wrapper);
|
||||||
|
|
||||||
|
List<CustomerDailySummaryVO> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ public class Customer {
|
|||||||
private String type;
|
private String type;
|
||||||
private String gender;
|
private String gender;
|
||||||
private String phone;
|
private String phone;
|
||||||
|
private String wechatId;
|
||||||
private String unionid;
|
private String unionid;
|
||||||
private LocalDateTime addTime;
|
private LocalDateTime addTime;
|
||||||
private String status;
|
private String status;
|
||||||
|
|||||||
@ -49,7 +49,8 @@ public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
|||||||
@Select("SELECT DISTINCT to_user FROM archive_messages WHERE corp_id = #{corpId} AND to_user IS NOT NULL AND to_user != ''")
|
@Select("SELECT DISTINCT to_user FROM archive_messages WHERE corp_id = #{corpId} AND to_user IS NOT NULL AND to_user != ''")
|
||||||
List<String> selectDistinctToUsers(@Param("corpId") String corpId);
|
List<String> 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<ArchiveMessage> selectLatestByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId, @Param("limit") int limit);
|
List<ArchiveMessage> 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}")
|
@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<ArchiveMessage> {
|
|||||||
@Select("SELECT COUNT(*) FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId}")
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId}")
|
||||||
Long countBySessionId(@Param("sessionId") String sessionId, @Param("corpId") String 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 " +
|
@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}")
|
"AND session_id IS NOT NULL GROUP BY session_id ORDER BY last_time DESC LIMIT #{offset}, #{limit}")
|
||||||
List<java.util.Map<String, Object>> selectSessionsByUser(@Param("userId") String userId, @Param("corpId") String corpId, @Param("offset") int offset, @Param("limit") int limit);
|
List<java.util.Map<String, Object>> 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);
|
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()")
|
@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);
|
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);
|
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}")
|
@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);
|
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);
|
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);
|
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);
|
Integer countMsgByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
@Select("SELECT DISTINCT u.staff_id FROM (" +
|
@Select("SELECT DISTINCT u.staff_id FROM (" +
|
||||||
|
|||||||
@ -412,8 +412,17 @@ public class ArchiveSessionService {
|
|||||||
vo.setStaffName(s != null && s.getName() != null ? s.getName() : toUser);
|
vo.setStaffName(s != null && s.getName() != null ? s.getName() : toUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后一条消息内容
|
// 最后一条消息内容(取 msgtime 最大的一条,兼容反向 session_id 的历史数据)
|
||||||
ArchiveMessage latest = archiveMessageMapper.selectBySessionId(sessionId, corpId, 0, 1).get(0);
|
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) {
|
if (latest != null) {
|
||||||
vo.setLastMsgContent(latest.getContent());
|
vo.setLastMsgContent(latest.getContent());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,6 +111,24 @@ public class DailySummaryService {
|
|||||||
return queryByExpr(customerId, expr, false);
|
return queryByExpr(customerId, expr, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询客户所有日期的消息总结(不按员工过滤,供运营按联系方式查询)
|
||||||
|
*
|
||||||
|
* @param customerId 客户企微外部联系人 userid
|
||||||
|
* @return 总结列表,按 msg_time 倒序
|
||||||
|
*/
|
||||||
|
public List<DailySummaryVO> 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<DailySummaryVO> queryByExpr(String customerId, String expr, boolean asc) {
|
private List<DailySummaryVO> queryByExpr(String customerId, String expr, boolean asc) {
|
||||||
log.info("Query Milvus daily summary, customerId={}, expr={}", customerId, expr);
|
log.info("Query Milvus daily summary, customerId={}, expr={}", customerId, expr);
|
||||||
|
|
||||||
|
|||||||
@ -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<DailySummaryVO> list;
|
||||||
|
}
|
||||||
134
fix_customer_names_server.py
Normal file
134
fix_customer_names_server.py
Normal file
@ -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()
|
||||||
@ -13,10 +13,15 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 每日总结独立页面
|
# 每日总结独立页面
|
||||||
location /summary {
|
location = /summary {
|
||||||
try_files /summary.html =404;
|
try_files /summary.html =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 运营按手机号/微信号查询客户总结
|
||||||
|
location = /summary-search {
|
||||||
|
try_files /summary-search.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
# 静态资源
|
# 静态资源
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@ -1,40 +1,24 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 CORPID = 'wwd483c2fba24ae30a'
|
||||||
const AGENTID = '1000021'
|
const AGENTID = '1000021'
|
||||||
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html')
|
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() {
|
function SummaryApp() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [list, setList] = useState<SummaryItem[]>([])
|
const [list, setList] = useState<SummaryItem[]>([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [needLogin, setNeedLogin] = useState(false)
|
const [needLogin, setNeedLogin] = useState(false)
|
||||||
|
const [customerId, setCustomerId] = useState('')
|
||||||
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [bindVisible, setBindVisible] = useState(false)
|
||||||
|
const [bindPhone, setBindPhone] = useState('')
|
||||||
|
const [bindWechatId, setBindWechatId] = useState('')
|
||||||
|
const [bindSaving, setBindSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@ -70,19 +54,6 @@ function SummaryApp() {
|
|||||||
init()
|
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<boolean> => {
|
const tryCodeLogin = async (code: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
|
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' })
|
Toast.show({ content: '缺少客户ID参数', icon: 'fail' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setCustomerId(customerId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@ -225,6 +197,10 @@ function SummaryApp() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.code === 0 && Array.isArray(data.data)) {
|
if (data.code === 0 && Array.isArray(data.data)) {
|
||||||
setList(data.data)
|
setList(data.data)
|
||||||
|
// 新客户当天还没有每日总结时,兜底展示最近 10 条聊天记录
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
loadRecentChat(customerId, staffId)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = data.message || '加载失败'
|
const msg = data.message || '加载失败'
|
||||||
setError(msg)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||||
@ -279,14 +317,6 @@ function SummaryApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (list.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 60 }}>
|
|
||||||
<Empty description="暂无每日总结" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@ -294,8 +324,12 @@ function SummaryApp() {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: '14px 16px',
|
padding: '14px 16px',
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
}}>
|
}}>
|
||||||
|
<div>
|
||||||
<div style={{ fontSize: 17, fontWeight: 600, color: '#333' }}>
|
<div style={{ fontSize: 17, fontWeight: 600, color: '#333' }}>
|
||||||
📋 每日总结
|
📋 每日总结
|
||||||
</div>
|
</div>
|
||||||
@ -303,358 +337,110 @@ function SummaryApp() {
|
|||||||
共 {list.length} 条 · 按时间倒序
|
共 {list.length} 条 · 按时间倒序
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{list.map((item, index) => (
|
onClick={openBindPopup}
|
||||||
<SummaryCard key={item.id || index} item={item} />
|
style={{
|
||||||
))}
|
padding: '7px 14px',
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryCard({ item }: { item: SummaryItem }) {
|
|
||||||
const parsed = parseSummary(item.summary)
|
|
||||||
const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '18px 16px',
|
|
||||||
marginBottom: 14,
|
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.05)'
|
|
||||||
}}>
|
|
||||||
{/* 头部 */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingBottom: 14,
|
|
||||||
marginBottom: 16,
|
|
||||||
borderBottom: '1px solid #f0f0f0'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<CalendarIcon />
|
|
||||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}>
|
|
||||||
{item.msgTime || formatDate(item.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<UserIcon />
|
|
||||||
<span style={{ fontSize: 14, color: '#666' }}>
|
|
||||||
{item.staffName || item.staffId || '未知老师'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasStructure ? (
|
|
||||||
<>
|
|
||||||
{parsed.sections.map((section, idx) => (
|
|
||||||
<SectionBlock key={idx} section={section} />
|
|
||||||
))}
|
|
||||||
{parsed.tags.length > 0 && <TagList tags={parsed.tags} />}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: 14, lineHeight: 1.8, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
||||||
{item.summary || item.messageContext || '无内容'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionBlock({ section }: { section: SummarySection }) {
|
|
||||||
const config = getSectionConfig(section.title)
|
|
||||||
const showTitle = displaySectionTitle(section.title)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 18 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 12,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: config.titleColor
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 16 }}>{config.icon}</span>
|
|
||||||
{showTitle}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
{section.items.map((text, idx) => (
|
|
||||||
<div key={idx} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
|
||||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
|
||||||
{section.title.includes('核心诉求') ? (
|
|
||||||
<NumberBadge n={idx + 1} />
|
|
||||||
) : section.title.includes('处理结果') ? (
|
|
||||||
<GreenCheck />
|
|
||||||
) : section.title.includes('待办') ? (
|
|
||||||
<OrangeClock />
|
|
||||||
) : (
|
|
||||||
<Dot color={config.titleColor} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 1.7,
|
|
||||||
color: '#333',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
flex: 1
|
|
||||||
}}>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagList({ tags }: { tags: string[] }) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 10,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#1677ff'
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 16 }}>🏷️</span>
|
|
||||||
关键标签
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
{tags.map((tag, idx) => (
|
|
||||||
<span key={idx} style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '5px 12px',
|
|
||||||
background: '#e6f4ff',
|
background: '#e6f4ff',
|
||||||
color: '#1677ff',
|
color: '#1677ff',
|
||||||
|
border: '1px solid #bae0ff',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
border: '1px solid #bae0ff'
|
cursor: 'pointer',
|
||||||
}}>
|
flexShrink: 0
|
||||||
#{tag}
|
}}
|
||||||
</span>
|
>
|
||||||
))}
|
绑定联系方式
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSectionConfig(title: string) {
|
{list.length === 0 ? (
|
||||||
if (title.includes('核心诉求')) {
|
chatMessages.length > 0 ? (
|
||||||
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 (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 20,
|
background: '#fff',
|
||||||
height: 20,
|
borderRadius: 12,
|
||||||
borderRadius: '50%',
|
padding: '12px 16px',
|
||||||
background: '#1677ff',
|
marginBottom: 12,
|
||||||
color: '#fff',
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
{n}
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#333' }}>
|
||||||
|
💬 最近聊天记录
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||||
|
暂无每日总结,展示最近 {chatMessages.length} 条聊天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChatMessageList messages={chatMessages} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无每日总结" style={{ padding: 60 }} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
list.map((item, index) => (
|
||||||
|
<SummaryCard key={item.id || index} item={item} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
visible={bindVisible}
|
||||||
|
onMaskClick={() => setBindVisible(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '20px 16px 32px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
||||||
|
绑定联系方式
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 20 }}>
|
||||||
|
绑定后运营可通过手机号或微信号查询该学员的每日总结
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>手机号</div>
|
||||||
|
<Input
|
||||||
|
value={bindPhone}
|
||||||
|
onChange={setBindPhone}
|
||||||
|
placeholder="请输入学员手机号"
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
style={{
|
||||||
|
'--font-size': '14px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px'
|
||||||
|
} as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>微信号</div>
|
||||||
|
<Input
|
||||||
|
value={bindWechatId}
|
||||||
|
onChange={setBindWechatId}
|
||||||
|
placeholder="请输入学员微信号"
|
||||||
|
maxLength={64}
|
||||||
|
style={{
|
||||||
|
'--font-size': '14px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px'
|
||||||
|
} as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
loading={bindSaving}
|
||||||
|
onClick={saveBindContact}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Popup>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GreenCheck() {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#f6ffed',
|
|
||||||
border: '1px solid #b7eb8f',
|
|
||||||
color: '#52c41a',
|
|
||||||
fontSize: 12,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
✓
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OrangeClock() {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#fff7e6',
|
|
||||||
border: '1px solid #ffd591',
|
|
||||||
color: '#fa8c16',
|
|
||||||
fontSize: 12,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
🕐
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dot({ color }: { color: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: color,
|
|
||||||
marginTop: 6
|
|
||||||
}} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
export default SummaryApp
|
||||||
|
|||||||
296
frontend/sidebar/src/SummarySearchApp.tsx
Normal file
296
frontend/sidebar/src/SummarySearchApp.tsx
Normal file
@ -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<CustomerSummaryGroup[] | null>(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<boolean> => {
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||||
|
<SpinLoading style={{ '--size': '32px' } as any} />
|
||||||
|
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needLogin) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#999', marginBottom: 24 }}>{error || '未获取到员工ID,请先登录'}</div>
|
||||||
|
<button
|
||||||
|
onClick={redirectToWeComOAuth}
|
||||||
|
style={{
|
||||||
|
padding: '10px 32px',
|
||||||
|
background: '#1677ff',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 15,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
企微一键登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
marginBottom: 14,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 600, color: '#333', marginBottom: 12 }}>
|
||||||
|
🔍 客户总结查询
|
||||||
|
</div>
|
||||||
|
<SearchBar
|
||||||
|
value={keyword}
|
||||||
|
onChange={setKeyword}
|
||||||
|
placeholder="输入手机号或微信号"
|
||||||
|
onSearch={doSearch}
|
||||||
|
showCancelButton={() => false}
|
||||||
|
style={{ '--border-radius': '8px', padding: 0 } as any}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={doSearch}
|
||||||
|
disabled={searching}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 0',
|
||||||
|
background: searching ? '#91caff' : '#1677ff',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: searching ? 'default' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searching ? '查询中...' : '查询'}
|
||||||
|
</button>
|
||||||
|
{groups !== null && groups.length > 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 10 }}>
|
||||||
|
共 {groups.length} 个客户 · {totalCount} 条总结
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups !== null && groups.length === 0 && (
|
||||||
|
<Empty description="未找到绑定该联系方式的客户" style={{ padding: 40 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(groups || []).map((group, gi) => (
|
||||||
|
<div key={group.customerId || gi}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 12,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#333' }}>
|
||||||
|
👤 {displayName(group)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'right' }}>
|
||||||
|
{group.phone && <div>📱 {group.phone}</div>}
|
||||||
|
{group.wechatId && <div>💬 {group.wechatId}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(group.list || []).length === 0 ? (
|
||||||
|
<RecentChatFallback customerId={group.customerId || ''} />
|
||||||
|
) : (
|
||||||
|
(group.list || []).map((item, index) => (
|
||||||
|
<SummaryCard key={item.id || index} item={item} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SummarySearchApp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户暂无每日总结时的兜底:加载最近 10 条单聊消息
|
||||||
|
*/
|
||||||
|
function RecentChatFallback({ customerId }: { customerId: string }) {
|
||||||
|
const [chatMessages, setChatMessages] = useState<ChatMessage[] | null>(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 (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<SpinLoading style={{ '--size': '20px' } as any} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatMessages.length === 0) {
|
||||||
|
return <Empty description="该客户暂无每日总结" style={{ padding: 24 }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '10px 16px',
|
||||||
|
marginBottom: 12,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#999'
|
||||||
|
}}>
|
||||||
|
暂无每日总结,展示最近 {chatMessages.length} 条聊天
|
||||||
|
</div>
|
||||||
|
<ChatMessageList messages={chatMessages} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/sidebar/src/components/ChatMessageList.tsx
Normal file
140
frontend/sidebar/src/components/ChatMessageList.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
background: '#f7f7f7',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '14px 10px',
|
||||||
|
marginBottom: 14,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||||
|
}}>
|
||||||
|
{ordered.map((msg, idx) => {
|
||||||
|
const isStaff = msg.fromRole === 'INTERNAL'
|
||||||
|
const name = senderName(msg)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id || msg.msgid || idx}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: isStaff ? 'row-reverse' : 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 14
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 头像:昵称首字 */}
|
||||||
|
<div style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isStaff ? '#1677ff' : '#52c41a',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '75%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: isStaff ? 'flex-end' : 'flex-start'
|
||||||
|
}}>
|
||||||
|
{/* 气泡 */}
|
||||||
|
<div style={{
|
||||||
|
background: isStaff ? '#95ec69' : '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: '#333',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.06)'
|
||||||
|
}}>
|
||||||
|
{displayContent(msg)}
|
||||||
|
</div>
|
||||||
|
{/* 昵称 + 消息类型 */}
|
||||||
|
<div style={{ fontSize: 11, color: '#999', marginTop: 4, display: 'flex', gap: 6 }}>
|
||||||
|
<span>{name}</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
background: '#eee',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: '16px'
|
||||||
|
}}>
|
||||||
|
{typeLabel(msg.msgtype)}
|
||||||
|
</span>
|
||||||
|
{msg.msgTimeStr && <span>{msg.msgTimeStr.slice(5, 16)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
374
frontend/sidebar/src/components/SummaryCard.tsx
Normal file
374
frontend/sidebar/src/components/SummaryCard.tsx
Normal file
@ -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 (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: '18px 16px',
|
||||||
|
marginBottom: 14,
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.05)'
|
||||||
|
}}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingBottom: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<CalendarIcon />
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}>
|
||||||
|
{item.msgTime || formatDate(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<UserIcon />
|
||||||
|
<span style={{ fontSize: 14, color: '#666' }}>
|
||||||
|
{item.staffName || item.staffId || '未知老师'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasStructure ? (
|
||||||
|
<>
|
||||||
|
{parsed.sections.map((section, idx) => (
|
||||||
|
<SectionBlock key={idx} section={section} />
|
||||||
|
))}
|
||||||
|
{parsed.tags.length > 0 && <TagList tags={parsed.tags} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.8, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{item.summary || item.messageContext || '无内容'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionBlock({ section }: { section: SummarySection }) {
|
||||||
|
const config = getSectionConfig(section.title)
|
||||||
|
const showTitle = displaySectionTitle(section.title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: config.titleColor
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>{config.icon}</span>
|
||||||
|
{showTitle}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{section.items.map((text, idx) => (
|
||||||
|
<div key={idx} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||||
|
{section.title.includes('核心诉求') ? (
|
||||||
|
<NumberBadge n={idx + 1} />
|
||||||
|
) : section.title.includes('处理结果') ? (
|
||||||
|
<GreenCheck />
|
||||||
|
) : section.title.includes('待办') ? (
|
||||||
|
<OrangeClock />
|
||||||
|
) : (
|
||||||
|
<Dot color={config.titleColor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: '#333',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagList({ tags }: { tags: string[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1677ff'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>🏷️</span>
|
||||||
|
关键标签
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{tags.map((tag, idx) => (
|
||||||
|
<span key={idx} style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '5px 12px',
|
||||||
|
background: '#e6f4ff',
|
||||||
|
color: '#1677ff',
|
||||||
|
borderRadius: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid #bae0ff'
|
||||||
|
}}>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#1677ff',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
{n}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GreenCheck() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
color: '#52c41a',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrangeClock() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffd591',
|
||||||
|
color: '#fa8c16',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
🕐
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
marginTop: 6
|
||||||
|
}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`
|
||||||
|
}
|
||||||
15
frontend/sidebar/src/services/http.ts
Normal file
15
frontend/sidebar/src/services/http.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 防缓存 fetch:URL 追加 _t 时间戳,并带上 no-cache 请求头
|
||||||
|
*/
|
||||||
|
export async function noCacheFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
10
frontend/sidebar/src/summary-search.tsx
Normal file
10
frontend/sidebar/src/summary-search.tsx
Normal file
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<SummarySearchApp />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
16
frontend/sidebar/summary-search.html
Normal file
16
frontend/sidebar/summary-search.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>客户总结查询</title>
|
||||||
|
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/summary-search.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -20,7 +20,8 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'index.html'),
|
index: resolve(__dirname, 'index.html'),
|
||||||
summary: resolve(__dirname, 'summary.html')
|
summary: resolve(__dirname, 'summary.html'),
|
||||||
|
'summary-search': resolve(__dirname, 'summary-search.html')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
init.sql
1
init.sql
@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `customers` (
|
|||||||
`type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业',
|
`type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业',
|
||||||
`gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女',
|
`gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女',
|
||||||
`phone` VARCHAR(32) COMMENT '手机号',
|
`phone` VARCHAR(32) COMMENT '手机号',
|
||||||
|
`wechat_id` VARCHAR(64) COMMENT '微信号',
|
||||||
`unionid` VARCHAR(128) COMMENT '微信unionid',
|
`unionid` VARCHAR(128) COMMENT '微信unionid',
|
||||||
`add_time` TIMESTAMP NULL COMMENT '添加时间',
|
`add_time` TIMESTAMP NULL COMMENT '添加时间',
|
||||||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除',
|
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user