feat: 新增运营端客户总结查询功能,优化现有总结页面

1.  新增客户联系方式查询客户每日总结的完整功能,包括前端页面、后端接口和数据库字段支持
2.  优化原有每日总结页面,新增绑定客户联系方式功能和无总结时的聊天记录兜底展示
3.  修复消息查询时的to_user数组匹配问题,新增客户微信号存储字段
4.  补充批量修复客户昵称的脚本和部署文档更新
This commit is contained in:
jqb 2026-08-04 09:49:10 +08:00
parent 70c6f1c789
commit 6a13b47328
20 changed files with 1454 additions and 408 deletions

View File

@ -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地址> ...` 执行。

View File

@ -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);
}
/** /**
* 获取消息详情 * 获取消息详情
*/ */

View File

@ -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("绑定成功");
}
}

View File

@ -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);
}
} }

View File

@ -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;

View File

@ -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 (" +

View File

@ -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());
} }

View File

@ -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);

View File

@ -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;
}

View 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()

View File

@ -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;

View File

@ -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

View 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} />
</>
)
}

View 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>
)
}

View 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')}`
}

View 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
}
})
}

View 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>,
)

View 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>

View File

@ -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')
} }
} }
} }

View File

@ -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-删除',