feat: 新增备忘提醒跟进功能
本次提交完成了备忘提醒全流程功能开发: 1. 新增reminders数据库表与初始化SQL脚本 2. 后端实现提醒CRUD、定时推送任务、企微消息推送 3. 侧边栏新增提醒页面,支持创建/查看/删除/标记已跟进提醒 4. 管理后台新增提醒管理页面,支持多条件查询与删除 5. 优化了会话总结的错误提示文案 6. 补充了企微应用agent-id配置项
This commit is contained in:
parent
72c7d557eb
commit
9c3268acdb
19
add_reminders.sql
Normal file
19
add_reminders.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- 备忘提醒跟进表
|
||||
-- 咨询老师在 sidebar 创建提醒,到点后通过企微自建应用 message/send 推送给老师
|
||||
CREATE TABLE IF NOT EXISTS `reminders` (
|
||||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||||
`staff_id` VARCHAR(128) NOT NULL COMMENT '咨询老师企微userid',
|
||||
`staff_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '咨询老师姓名',
|
||||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员external_userid',
|
||||
`customer_name` VARCHAR(256) NOT NULL DEFAULT '' COMMENT '学员姓名',
|
||||
`remind_time` DATETIME NOT NULL COMMENT '提醒时间(精确到小时,分秒为0)',
|
||||
`content` VARCHAR(1024) NOT NULL COMMENT '提醒文字',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待推送 NOTIFIED已推送 FAILED推送失败',
|
||||
`notify_time` DATETIME NULL COMMENT '实际推送时间',
|
||||
`retry_count` INT(11) NOT NULL DEFAULT 0 COMMENT '推送重试次数',
|
||||
`deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_staff_time` (`staff_id`, `remind_time`) COMMENT '老师+提醒时间',
|
||||
INDEX `idx_status_time` (`status`, `remind_time`) COMMENT '状态+提醒时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='备忘提醒跟进表';
|
||||
@ -5,11 +5,15 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -28,6 +32,9 @@ public class WeComApiClient {
|
||||
@Value("${wecom.agent.secret:}")
|
||||
private String agentSecret;
|
||||
|
||||
@Value("${wecom.agent.agent-id:1000021}")
|
||||
private String agentId;
|
||||
|
||||
@Value("${wecom.contact.secret:}")
|
||||
private String contactSecret;
|
||||
|
||||
@ -87,6 +94,48 @@ public class WeComApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送应用文本消息给员工
|
||||
*
|
||||
* @param toUser 员工企微userid
|
||||
* @param content 文本内容
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendTextMessage(String toUser, String content) {
|
||||
String accessToken = getAccessToken();
|
||||
if (accessToken == null) {
|
||||
log.error("发送企微消息失败: 无法获取access_token, toUser={}", toUser);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + accessToken;
|
||||
Map<String, Object> text = new HashMap<>();
|
||||
text.put("content", content);
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("touser", toUser);
|
||||
body.put("msgtype", "text");
|
||||
// 企微要求 agentid 为整数,字符串会报 40008 invalid message type
|
||||
body.put("agentid", Integer.valueOf(agentId.trim()));
|
||||
body.put("text", text);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
|
||||
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||
|
||||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||
log.error("发送企微消息失败: toUser={}, resp={}", toUser, response.getBody());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("发送企微消息异常: toUser={}", toUser, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取员工详情
|
||||
*/
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
package com.artedu.archive.controller;
|
||||
|
||||
import com.artedu.archive.entity.Reminder;
|
||||
import com.artedu.archive.service.ReminderService;
|
||||
import com.artedu.common.result.PageResult;
|
||||
import com.artedu.common.result.Result;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 备忘提醒跟进接口
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/archive/reminders")
|
||||
public class ReminderController {
|
||||
|
||||
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private ReminderService reminderService;
|
||||
|
||||
/**
|
||||
* 创建提醒(咨询老师在 sidebar 调用)
|
||||
*/
|
||||
@PostMapping("")
|
||||
public Result<Reminder> create(@RequestBody CreateRequest request) {
|
||||
Reminder reminder = new Reminder();
|
||||
reminder.setStaffId(request.getStaffId());
|
||||
reminder.setStaffName(request.getStaffName());
|
||||
reminder.setCustomerId(request.getCustomerId());
|
||||
reminder.setCustomerName(request.getCustomerName());
|
||||
reminder.setContent(request.getContent());
|
||||
try {
|
||||
reminder.setRemindTime(parseRemindTime(request.getRemindTime()));
|
||||
return Result.success(reminderService.create(reminder));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.fail(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("创建提醒失败", e);
|
||||
return Result.fail("创建提醒失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询老师今日(北京时间)的提醒列表
|
||||
*/
|
||||
@GetMapping("/today")
|
||||
public Result<List<Reminder>> listToday(@RequestParam String staffId) {
|
||||
if (staffId == null || staffId.trim().isEmpty()) {
|
||||
return Result.fail("staffId 不能为空");
|
||||
}
|
||||
return Result.success(reminderService.listToday(staffId.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师删除自己的提醒
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> deleteByStaff(@PathVariable Long id, @RequestParam String staffId) {
|
||||
if (staffId == null || staffId.trim().isEmpty()) {
|
||||
return Result.fail("staffId 不能为空");
|
||||
}
|
||||
if (reminderService.deleteByStaff(id, staffId.trim())) {
|
||||
return Result.success();
|
||||
}
|
||||
return Result.fail("删除失败:提醒不存在或无权限");
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师标记提醒为已跟进(已跟进的不再推送、今日列表不再显示)
|
||||
*/
|
||||
@PostMapping("/{id}/done")
|
||||
public Result<Void> markDone(@PathVariable Long id, @RequestParam String staffId) {
|
||||
if (staffId == null || staffId.trim().isEmpty()) {
|
||||
return Result.fail("staffId 不能为空");
|
||||
}
|
||||
if (reminderService.markDoneByStaff(id, staffId.trim())) {
|
||||
return Result.success();
|
||||
}
|
||||
return Result.fail("操作失败:提醒不存在或无权限");
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端分页查询
|
||||
*
|
||||
* @param date yyyy-MM-dd,匹配 remind_time 当天
|
||||
*/
|
||||
@GetMapping("/admin/page")
|
||||
public Result<PageResult<Reminder>> adminPage(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) String staffName,
|
||||
@RequestParam(required = false) String customerName,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String date) {
|
||||
try {
|
||||
return Result.success(reminderService.adminPage(page, size, staffName, customerName, status, date));
|
||||
} catch (DateTimeParseException e) {
|
||||
return Result.fail("date 格式应为 yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端删除提醒
|
||||
*/
|
||||
@DeleteMapping("/admin/{id}")
|
||||
public Result<Void> adminDelete(@PathVariable Long id) {
|
||||
if (reminderService.adminDelete(id)) {
|
||||
return Result.success();
|
||||
}
|
||||
return Result.fail("删除失败:提醒不存在");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析提醒时间,支持 "yyyy-MM-dd HH:mm:ss" 和 ISO(yyyy-MM-ddTHH:mm:ss)
|
||||
*/
|
||||
private LocalDateTime parseRemindTime(String remindTime) {
|
||||
if (remindTime == null || remindTime.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String value = remindTime.trim();
|
||||
try {
|
||||
return LocalDateTime.parse(value, DATETIME_FORMAT);
|
||||
} catch (DateTimeParseException e) {
|
||||
try {
|
||||
return LocalDateTime.parse(value);
|
||||
} catch (DateTimeParseException e2) {
|
||||
throw new IllegalArgumentException("remindTime 格式应为 yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CreateRequest {
|
||||
private String staffId;
|
||||
private String staffName;
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String remindTime;
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.artedu.archive.entity;
|
||||
|
||||
import com.artedu.common.entity.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 备忘提醒跟进实体
|
||||
* 对应reminders表
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("reminders")
|
||||
public class Reminder extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 咨询老师企微userid
|
||||
*/
|
||||
private String staffId;
|
||||
|
||||
/**
|
||||
* 咨询老师姓名
|
||||
*/
|
||||
private String staffName;
|
||||
|
||||
/**
|
||||
* 学员external_userid
|
||||
*/
|
||||
private String customerId;
|
||||
|
||||
/**
|
||||
* 学员姓名
|
||||
*/
|
||||
private String customerName;
|
||||
|
||||
/**
|
||||
* 提醒时间(精确到小时,分秒为0),北京时间
|
||||
*/
|
||||
private LocalDateTime remindTime;
|
||||
|
||||
/**
|
||||
* 提醒文字
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 状态:PENDING待推送 NOTIFIED已推送 FAILED推送失败
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 实际推送时间
|
||||
*/
|
||||
private LocalDateTime notifyTime;
|
||||
|
||||
/**
|
||||
* 推送重试次数
|
||||
*/
|
||||
private Integer retryCount;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.artedu.archive.mapper;
|
||||
|
||||
import com.artedu.archive.entity.Reminder;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 备忘提醒Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReminderMapper extends BaseMapper<Reminder> {
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.artedu.archive.service;
|
||||
|
||||
import com.artedu.archive.client.WeComApiClient;
|
||||
import com.artedu.archive.entity.Reminder;
|
||||
import com.artedu.archive.mapper.ReminderMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 备忘提醒推送定时任务
|
||||
* 每分钟扫描到点未推送的提醒,通过企微自建应用 message/send 推送给咨询老师。
|
||||
* 数据库 DATETIME 存北京时间,这里显式用 Asia/Shanghai 计算当前时间
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ReminderNotifyJob {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00");
|
||||
private static final int MAX_RETRY = 5;
|
||||
private static final int BATCH_SIZE = 50;
|
||||
|
||||
@Autowired
|
||||
private ReminderMapper reminderMapper;
|
||||
|
||||
@Autowired
|
||||
private WeComApiClient weComApiClient;
|
||||
|
||||
@Scheduled(fixedRate = 60000, initialDelay = 30000)
|
||||
public void notifyDue() {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||
|
||||
LambdaQueryWrapper<Reminder> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Reminder::getStatus, ReminderService.STATUS_PENDING)
|
||||
.le(Reminder::getRemindTime, now)
|
||||
.orderByAsc(Reminder::getRemindTime)
|
||||
.last("LIMIT " + BATCH_SIZE);
|
||||
List<Reminder> dueList = reminderMapper.selectList(wrapper);
|
||||
|
||||
if (dueList == null || dueList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("备忘提醒推送: 本次到期 {} 条", dueList.size());
|
||||
|
||||
for (Reminder reminder : dueList) {
|
||||
try {
|
||||
notifyOne(reminder, now);
|
||||
} catch (Exception e) {
|
||||
log.error("备忘提醒推送异常: id={}", reminder.getId(), e);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("备忘提醒推送任务失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyOne(Reminder reminder, LocalDateTime now) {
|
||||
String text = "【跟进提醒】学员:" + (reminder.getCustomerName() != null ? reminder.getCustomerName() : "")
|
||||
+ "\n" + reminder.getContent()
|
||||
+ "\n(提醒时间:" + reminder.getRemindTime().format(HOUR_FORMAT) + ")";
|
||||
|
||||
boolean ok = weComApiClient.sendTextMessage(reminder.getStaffId(), text);
|
||||
|
||||
Reminder update = new Reminder();
|
||||
update.setId(reminder.getId());
|
||||
if (ok) {
|
||||
update.setStatus(ReminderService.STATUS_NOTIFIED);
|
||||
update.setNotifyTime(now);
|
||||
reminderMapper.updateById(update);
|
||||
log.info("备忘提醒已推送: id={}, staffId={}", reminder.getId(), reminder.getStaffId());
|
||||
} else {
|
||||
int retryCount = (reminder.getRetryCount() != null ? reminder.getRetryCount() : 0) + 1;
|
||||
update.setRetryCount(retryCount);
|
||||
if (retryCount >= MAX_RETRY) {
|
||||
update.setStatus(ReminderService.STATUS_FAILED);
|
||||
}
|
||||
reminderMapper.updateById(update);
|
||||
log.warn("备忘提醒推送失败: id={}, staffId={}, retryCount={}", reminder.getId(), reminder.getStaffId(), retryCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
package com.artedu.archive.service;
|
||||
|
||||
import com.artedu.archive.client.WeComApiClient;
|
||||
import com.artedu.archive.entity.Reminder;
|
||||
import com.artedu.archive.mapper.ReminderMapper;
|
||||
import com.artedu.common.result.PageResult;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 备忘提醒跟进服务
|
||||
* 数据库 DATETIME 统一存北京时间,计算"今天/当前小时"时显式使用 Asia/Shanghai,
|
||||
* 避免容器默认 UTC 时区造成偏差
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ReminderService {
|
||||
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_NOTIFIED = "NOTIFIED";
|
||||
public static final String STATUS_FAILED = "FAILED";
|
||||
public static final String STATUS_DONE = "DONE";
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Autowired
|
||||
private ReminderMapper reminderMapper;
|
||||
|
||||
@Autowired
|
||||
private WeComApiClient weComApiClient;
|
||||
|
||||
/**
|
||||
* 创建提醒
|
||||
*
|
||||
* @param req 请求参数(staffId/customerId/remindTime/content 必填)
|
||||
* @return 落库后的实体
|
||||
* @throws IllegalArgumentException 参数校验失败时抛出
|
||||
*/
|
||||
public Reminder create(Reminder req) {
|
||||
if (req.getStaffId() == null || req.getStaffId().trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("staffId 不能为空");
|
||||
}
|
||||
if (req.getCustomerId() == null || req.getCustomerId().trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("customerId 不能为空");
|
||||
}
|
||||
if (req.getContent() == null || req.getContent().trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("content 不能为空");
|
||||
}
|
||||
if (req.getRemindTime() == null) {
|
||||
throw new IllegalArgumentException("remindTime 不能为空");
|
||||
}
|
||||
|
||||
// 提醒时间精确到小时,分秒清零
|
||||
LocalDateTime remindTime = req.getRemindTime().withMinute(0).withSecond(0).withNano(0);
|
||||
LocalDateTime currentHour = LocalDateTime.now(ZONE).withMinute(0).withSecond(0).withNano(0);
|
||||
if (remindTime.isBefore(currentHour)) {
|
||||
throw new IllegalArgumentException("提醒时间不能早于当前小时");
|
||||
}
|
||||
|
||||
Reminder reminder = new Reminder();
|
||||
reminder.setStaffId(req.getStaffId().trim());
|
||||
reminder.setCustomerId(req.getCustomerId().trim());
|
||||
reminder.setRemindTime(remindTime);
|
||||
reminder.setContent(req.getContent().trim());
|
||||
reminder.setStaffName(resolveStaffName(req.getStaffId().trim(), req.getStaffName()));
|
||||
reminder.setCustomerName(resolveCustomerName(req.getCustomerId().trim(), req.getCustomerName()));
|
||||
reminder.setStatus(STATUS_PENDING);
|
||||
reminder.setRetryCount(0);
|
||||
|
||||
reminderMapper.insert(reminder);
|
||||
return reminder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询老师北京时间今日的提醒,按提醒时间升序(不含已跟进的)
|
||||
*/
|
||||
public List<Reminder> listToday(String staffId) {
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDateTime start = today.atStartOfDay();
|
||||
LocalDateTime end = today.atTime(23, 59, 59);
|
||||
|
||||
LambdaQueryWrapper<Reminder> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Reminder::getStaffId, staffId)
|
||||
.between(Reminder::getRemindTime, start, end)
|
||||
.ne(Reminder::getStatus, STATUS_DONE)
|
||||
.orderByAsc(Reminder::getRemindTime);
|
||||
return reminderMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师标记提醒为已跟进(仅能标记本人的;已跟进的不再推送、今日列表不再显示)
|
||||
*/
|
||||
public boolean markDoneByStaff(Long id, String staffId) {
|
||||
Reminder reminder = reminderMapper.selectById(id);
|
||||
if (reminder == null) {
|
||||
return false;
|
||||
}
|
||||
if (!reminder.getStaffId().equals(staffId)) {
|
||||
log.warn("标记已跟进被拒绝: id={}, staffId={} 非本人记录", id, staffId);
|
||||
return false;
|
||||
}
|
||||
Reminder update = new Reminder();
|
||||
update.setId(id);
|
||||
update.setStatus(STATUS_DONE);
|
||||
return reminderMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师删除自己的提醒(逻辑删除,仅能删本人的)
|
||||
*/
|
||||
public boolean deleteByStaff(Long id, String staffId) {
|
||||
Reminder reminder = reminderMapper.selectById(id);
|
||||
if (reminder == null) {
|
||||
return false;
|
||||
}
|
||||
if (!reminder.getStaffId().equals(staffId)) {
|
||||
log.warn("删除提醒被拒绝: id={}, staffId={} 非本人记录", id, staffId);
|
||||
return false;
|
||||
}
|
||||
return reminderMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端分页查询
|
||||
*
|
||||
* @param date yyyy-MM-dd,匹配 remind_time 当天
|
||||
*/
|
||||
public PageResult<Reminder> adminPage(int page, int size, String staffName, String customerName,
|
||||
String status, String date) {
|
||||
LambdaQueryWrapper<Reminder> wrapper = new LambdaQueryWrapper<>();
|
||||
if (staffName != null && !staffName.trim().isEmpty()) {
|
||||
wrapper.like(Reminder::getStaffName, staffName.trim());
|
||||
}
|
||||
if (customerName != null && !customerName.trim().isEmpty()) {
|
||||
wrapper.like(Reminder::getCustomerName, customerName.trim());
|
||||
}
|
||||
if (status != null && !status.trim().isEmpty()) {
|
||||
wrapper.eq(Reminder::getStatus, status.trim());
|
||||
}
|
||||
if (date != null && !date.trim().isEmpty()) {
|
||||
LocalDate day = LocalDate.parse(date.trim());
|
||||
wrapper.between(Reminder::getRemindTime, day.atStartOfDay(), day.atTime(23, 59, 59));
|
||||
}
|
||||
wrapper.orderByDesc(Reminder::getCreatedAt);
|
||||
|
||||
Page<Reminder> result = reminderMapper.selectPage(new Page<>(page, size), wrapper);
|
||||
return PageResult.of(page, size, result.getTotal(), result.getRecords());
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端删除提醒(逻辑删除)
|
||||
*/
|
||||
public boolean adminDelete(Long id) {
|
||||
return reminderMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 员工姓名为空时从企微接口补全,取不到则存 staffId
|
||||
*/
|
||||
private String resolveStaffName(String staffId, String staffName) {
|
||||
if (staffName != null && !staffName.trim().isEmpty()) {
|
||||
return staffName.trim();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> detail = weComApiClient.getUserDetail(staffId);
|
||||
Object name = detail.get("name");
|
||||
if (name != null && !name.toString().isEmpty()) {
|
||||
return name.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取员工姓名失败: staffId={}, err={}", staffId, e.getMessage());
|
||||
}
|
||||
return staffId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学员姓名为空时从企微接口补全,取不到则存 customerId
|
||||
*/
|
||||
private String resolveCustomerName(String customerId, String customerName) {
|
||||
if (customerName != null && !customerName.trim().isEmpty()) {
|
||||
return customerName.trim();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> detail = weComApiClient.getExternalContact(customerId);
|
||||
Object name = detail.get("name");
|
||||
if (name != null && !name.toString().isEmpty()) {
|
||||
return name.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取学员姓名失败: customerId={}, err={}", customerId, e.getMessage());
|
||||
}
|
||||
return customerId;
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,7 @@ wecom:
|
||||
corp-id: wwd483c2fba24ae30a
|
||||
agent:
|
||||
secret: ${WECOM_AGENT_SECRET:}
|
||||
agent-id: ${WECOM_AGENT_ID:1000021}
|
||||
contact:
|
||||
secret: ${WECOM_CONTACT_SECRET:}
|
||||
archive:
|
||||
|
||||
@ -15,6 +15,7 @@ import MessageSearch from './pages/MessageSearch'
|
||||
import LlmModelList from './pages/LlmModelList'
|
||||
import ChatMining from './pages/ChatMining'
|
||||
import CosConfig from './pages/CosConfig'
|
||||
import ReminderList from './pages/ReminderList'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -37,6 +38,7 @@ function App() {
|
||||
<Route path="llm-models" element={<LlmModelList />} />
|
||||
<Route path="chat-mining" element={<ChatMining />} />
|
||||
<Route path="cos-config" element={<CosConfig />} />
|
||||
<Route path="reminders" element={<ReminderList />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd'
|
||||
import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, RobotOutlined, GoldOutlined, CloudUploadOutlined } from '@ant-design/icons'
|
||||
import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, RobotOutlined, GoldOutlined, CloudUploadOutlined, BellOutlined } from '@ant-design/icons'
|
||||
import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification'
|
||||
|
||||
const { Header, Sider, Content } = AntLayout
|
||||
@ -79,6 +79,7 @@ export default function Layout() {
|
||||
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
|
||||
{ key: '/cos-config', icon: <CloudUploadOutlined />, label: 'COS配置' },
|
||||
{ key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' },
|
||||
{ key: '/reminders', icon: <BellOutlined />, label: '提醒管理' },
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
144
frontend/admin/src/pages/ReminderList.tsx
Normal file
144
frontend/admin/src/pages/ReminderList.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Tag, Space, Input, Select, DatePicker, Card, message, Popconfirm } from 'antd'
|
||||
import request from '../utils/request'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'PENDING', label: '待推送' },
|
||||
{ value: 'NOTIFIED', label: '已推送' },
|
||||
{ value: 'FAILED', label: '推送失败' },
|
||||
{ value: 'DONE', label: '已跟进' },
|
||||
]
|
||||
|
||||
const STATUS_TAG: Record<string, { color: string; text: string }> = {
|
||||
PENDING: { color: 'blue', text: '待推送' },
|
||||
NOTIFIED: { color: 'green', text: '已推送' },
|
||||
FAILED: { color: 'red', text: '推送失败' },
|
||||
DONE: { color: 'default', text: '已跟进' },
|
||||
}
|
||||
|
||||
export default function ReminderList() {
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 })
|
||||
const [staffName, setStaffName] = useState('')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [status, setStatus] = useState<string | undefined>(undefined)
|
||||
const [date, setDate] = useState<string | undefined>(undefined)
|
||||
|
||||
const loadData = async (page = 1, size = 20) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await request.get('/v1/archive/reminders/admin/page', {
|
||||
params: {
|
||||
page,
|
||||
size,
|
||||
staffName: staffName || undefined,
|
||||
customerName: customerName || undefined,
|
||||
status: status || undefined,
|
||||
date: date || undefined,
|
||||
},
|
||||
})
|
||||
setData(res.data.records || [])
|
||||
setPagination({ current: page, pageSize: size, total: res.data.total || 0 })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const handleSearch = () => {
|
||||
loadData(1, pagination.pageSize)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await request.delete(`/v1/archive/reminders/admin/${id}`)
|
||||
message.success('删除成功')
|
||||
loadData(pagination.current, pagination.pageSize)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 后端返回 ISO 格式(2026-08-11T10:00:00),替换 T 为空格展示
|
||||
const formatTime = (v?: string) => (v ? v.replace('T', ' ') : '-')
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '咨询老师', dataIndex: 'staffName', width: 120, render: (v: string) => v || '-' },
|
||||
{ title: '学员', dataIndex: 'customerName', width: 120, render: (v: string) => v || '-' },
|
||||
{ title: '提醒时间', dataIndex: 'remindTime', width: 160, render: (v: string) => formatTime(v) },
|
||||
{ title: '提醒内容', dataIndex: 'content', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const tag = STATUS_TAG[s] || { color: 'default', text: s || '-' }
|
||||
return <Tag color={tag.color}>{tag.text}</Tag>
|
||||
},
|
||||
},
|
||||
{ title: '推送时间', dataIndex: 'notifyTime', width: 160, render: (v: string) => formatTime(v) },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: (v: string) => formatTime(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Popconfirm title="确认删除该提醒?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="老师姓名"
|
||||
value={staffName}
|
||||
onChange={(e) => setStaffName(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<Input
|
||||
placeholder="学员姓名"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="提醒日期"
|
||||
onChange={(d) => setDate(d ? d.format('YYYY-MM-DD') : undefined)}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>查询</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (p, s) => loadData(p, s),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -32,6 +32,11 @@ server {
|
||||
try_files /summary-search.html =404;
|
||||
}
|
||||
|
||||
# 跟进提醒独立页面
|
||||
location = /reminder {
|
||||
try_files /reminder.html =404;
|
||||
}
|
||||
|
||||
# 静态资源
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
16
frontend/sidebar/reminder.html
Normal file
16
frontend/sidebar/reminder.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/reminder.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
575
frontend/sidebar/src/ReminderApp.tsx
Normal file
575
frontend/sidebar/src/ReminderApp.tsx
Normal file
@ -0,0 +1,575 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SpinLoading, Empty, Toast, Dialog, Button, TextArea, DatePicker, List } from 'antd-mobile'
|
||||
import { noCacheFetch } from './services/http'
|
||||
|
||||
const CORPID = 'wwd483c2fba24ae30a'
|
||||
const AGENTID = '1000021'
|
||||
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/reminder')
|
||||
|
||||
interface ReminderItem {
|
||||
id: number | string
|
||||
staffId?: string
|
||||
staffName?: string
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
remindTime?: string
|
||||
content?: string
|
||||
status?: string
|
||||
notifyTime?: string | null
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; color: string; bg: string }> = {
|
||||
PENDING: { text: '待推送', color: '#1677ff', bg: '#e6f4ff' },
|
||||
NOTIFIED: { text: '已推送', color: '#52c41a', bg: '#f6ffed' },
|
||||
FAILED: { text: '推送失败', color: '#cf1322', bg: '#fff2f0' }
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// yyyy-MM-dd HH:00
|
||||
const formatHour = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:00`
|
||||
|
||||
// yyyy-MM-dd HH:00:00(提交给后端)
|
||||
const formatHourFull = (d: Date) => `${formatHour(d)}:00`
|
||||
|
||||
// 后端返回的时间字符串(可能是 "yyyy-MM-dd HH:mm:ss" 或 ISO),展示到分钟
|
||||
const formatTimeDisplay = (s?: string | null): string => {
|
||||
if (!s) return ''
|
||||
const t = s.replace('T', ' ')
|
||||
return t.length >= 16 ? t.slice(0, 16) : t
|
||||
}
|
||||
|
||||
function ReminderApp() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [needLogin, setNeedLogin] = useState(false)
|
||||
const [staffId, setStaffId] = useState('')
|
||||
const [staffName, setStaffName] = useState('')
|
||||
const [customerId, setCustomerId] = useState('')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [list, setList] = useState<ReminderItem[]>([])
|
||||
|
||||
// 创建表单
|
||||
const [pickerVisible, setPickerVisible] = useState(false)
|
||||
const [remindDate, setRemindDate] = useState<Date | null>(null)
|
||||
const [content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
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 sid = urlParams.get('staffId') || localStorage.getItem('staff_user_id') || ''
|
||||
if (!sid) {
|
||||
setLoading(false)
|
||||
setNeedLogin(true)
|
||||
setError('未获取到员工ID,请先登录')
|
||||
return
|
||||
}
|
||||
setStaffId(sid)
|
||||
setStaffName(localStorage.getItem('staff_user_name') || '')
|
||||
|
||||
// 3. 获取当前学员并加载今日提醒
|
||||
await initCustomer()
|
||||
await loadTodayReminders(sid)
|
||||
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=reminder#wechat_redirect`
|
||||
console.log('跳转企微 OAuth:', oauthUrl)
|
||||
window.location.href = oauthUrl
|
||||
}
|
||||
|
||||
const initWxConfigAndGetCustomerId = (): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const wx = (window as any).wx
|
||||
if (!wx || !wx.config) {
|
||||
console.log('非企微环境,跳过 JS-SDK')
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
const currentUrl = window.location.href.split('#')[0]
|
||||
|
||||
noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
|
||||
.then(res => res.json())
|
||||
.then(corpData => {
|
||||
if (corpData.code !== 0 || !corpData.data) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
const cfg = corpData.data
|
||||
wx.config({
|
||||
beta: true,
|
||||
debug: false,
|
||||
appId: cfg.corpId,
|
||||
timestamp: cfg.timestamp,
|
||||
nonceStr: cfg.nonceStr,
|
||||
signature: cfg.signature,
|
||||
jsApiList: ['agentConfig', 'getContext', 'getCurExternalContact']
|
||||
})
|
||||
|
||||
wx.ready(() => {
|
||||
noCacheFetch(`/api/v1/auth/signature?type=agent&agentId=${AGENTID}&url=${encodeURIComponent(currentUrl)}`)
|
||||
.then(res => res.json())
|
||||
.then(agentData => {
|
||||
if (agentData.code !== 0 || !agentData.data) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
const ac = agentData.data
|
||||
wx.agentConfig({
|
||||
corpid: ac.corpId,
|
||||
agentid: ac.agentId,
|
||||
timestamp: ac.timestamp,
|
||||
nonceStr: ac.nonceStr,
|
||||
signature: ac.signature,
|
||||
jsApiList: ['getContext', 'getCurExternalContact'],
|
||||
success: () => {
|
||||
wx.invoke('getCurExternalContact', {}, (res: any) => {
|
||||
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
|
||||
console.log('JS-SDK 获取客户ID:', res.userId)
|
||||
resolve(res.userId)
|
||||
} else {
|
||||
console.warn('getCurExternalContact failed:', res)
|
||||
resolve('')
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('agentConfig fail:', err)
|
||||
resolve('')
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error('agentConfig fetch error:', e)
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
|
||||
wx.error((err: any) => {
|
||||
console.error('wx.config error:', err)
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error('initWxConfig error:', e)
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 当前学员:JS-SDK → URL customerId 参数 → localStorage 兜底
|
||||
const initCustomer = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let cid = urlParams.get('customerId') || ''
|
||||
|
||||
const sdkCustomerId = await initWxConfigAndGetCustomerId()
|
||||
if (sdkCustomerId) {
|
||||
cid = sdkCustomerId
|
||||
localStorage.setItem('current_customer_id', sdkCustomerId)
|
||||
} else {
|
||||
cid = cid || localStorage.getItem('current_customer_id') || ''
|
||||
}
|
||||
|
||||
if (!cid) return
|
||||
setCustomerId(cid)
|
||||
|
||||
// 尝试获取学员姓名
|
||||
try {
|
||||
const res = await noCacheFetch(`/api/v1/archive/customers/${encodeURIComponent(cid)}`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data && data.data.name) {
|
||||
setCustomerName(data.data.name)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取学员信息失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTodayReminders = async (sid: string) => {
|
||||
try {
|
||||
const res = await noCacheFetch(`/api/v1/archive/reminders/today?staffId=${encodeURIComponent(sid)}`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && Array.isArray(data.data)) {
|
||||
setList(data.data)
|
||||
} else {
|
||||
console.warn('加载今日提醒失败:', data.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载今日提醒失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const submitReminder = async () => {
|
||||
if (!remindDate) {
|
||||
Toast.show({ content: '请选择提醒时间', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
if (remindDate.getTime() <= Date.now()) {
|
||||
Toast.show({ content: '提醒时间需晚于当前时间', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
const text = content.trim()
|
||||
if (!text) {
|
||||
Toast.show({ content: '请填写提醒内容', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
if (!customerId) {
|
||||
Toast.show({ content: '未获取到当前学员,请从企微会话侧边栏进入', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await noCacheFetch('/api/v1/archive/reminders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
staffId,
|
||||
staffName,
|
||||
customerId,
|
||||
customerName,
|
||||
remindTime: formatHourFull(remindDate),
|
||||
content: text
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
Toast.show({ content: '提醒创建成功', icon: 'success' })
|
||||
setRemindDate(null)
|
||||
setContent('')
|
||||
loadTodayReminders(staffId)
|
||||
} else {
|
||||
Toast.show({ content: data.message || '创建失败', icon: 'fail' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('创建提醒失败:', e)
|
||||
Toast.show({ content: '网络错误', icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteReminder = async (item: ReminderItem) => {
|
||||
const confirmed = await Dialog.confirm({
|
||||
content: '确定删除这条提醒吗?'
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await noCacheFetch(
|
||||
`/api/v1/archive/reminders/${item.id}?staffId=${encodeURIComponent(staffId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
Toast.show({ content: '已删除', icon: 'success' })
|
||||
setList(prev => prev.filter(r => r.id !== item.id))
|
||||
} else {
|
||||
Toast.show({ content: data.message || '删除失败', icon: 'fail' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除提醒失败:', e)
|
||||
Toast.show({ content: '网络错误', icon: 'fail' })
|
||||
}
|
||||
}
|
||||
|
||||
// 标记已跟进:已跟进的提醒不再推送、今日列表不再显示
|
||||
const markDone = async (item: ReminderItem) => {
|
||||
const confirmed = await Dialog.confirm({
|
||||
content: '确认已跟进该学员吗?标记后不再显示'
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await noCacheFetch(
|
||||
`/api/v1/archive/reminders/${item.id}/done?staffId=${encodeURIComponent(staffId)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
Toast.show({ content: '已标记跟进', icon: 'success' })
|
||||
setList(prev => prev.filter(r => r.id !== item.id))
|
||||
} else {
|
||||
Toast.show({ content: data.message || '操作失败', icon: 'fail' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('标记已跟进失败:', e)
|
||||
Toast.show({ content: '网络错误', icon: 'fail' })
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||
<div style={{ color: '#999', fontSize: 14 }}>{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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' }}>
|
||||
⏰ 跟进提醒
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
{staffName ? `${staffName} · ` : ''}到点通过企微推送提醒
|
||||
</div>
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid #f0f0f0' }}>
|
||||
{customerId ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: '#666' }}>当前学员</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}>
|
||||
{customerName || customerId}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: '#fa8c16' }}>
|
||||
未获取到当前学员,请从企微会话侧边栏进入
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建提醒表单 */}
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#333', marginBottom: 12 }}>
|
||||
新建提醒
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>提醒时间</div>
|
||||
<div
|
||||
onClick={() => setPickerVisible(true)}
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: remindDate ? '#333' : '#999',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{remindDate ? formatHour(remindDate) : '请选择提醒时间(精确到小时)'}
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={pickerVisible}
|
||||
precision="hour"
|
||||
min={new Date()}
|
||||
onClose={() => setPickerVisible(false)}
|
||||
onCancel={() => setPickerVisible(false)}
|
||||
onConfirm={(val) => {
|
||||
setRemindDate(val)
|
||||
setPickerVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>提醒内容</div>
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="例:约张三家长确认试听课时间"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
style={{
|
||||
'--font-size': '14px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px'
|
||||
} as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={submitting}
|
||||
onClick={submitReminder}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
创建提醒
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 今日提醒列表 */}
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#333' }}>
|
||||
📅 今日提醒
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
共 {list.length} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<Empty description="今日暂无提醒" style={{ padding: 40 }} />
|
||||
) : (
|
||||
<List style={{ '--border-top': 'none', '--border-bottom': 'none' } as any}>
|
||||
{list.map((item) => {
|
||||
const status = STATUS_MAP[item.status || ''] || { text: item.status || '未知', color: '#999', bg: '#f5f5f5' }
|
||||
return (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
marginBottom: 10,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}
|
||||
extra={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Button
|
||||
size="mini"
|
||||
color="primary"
|
||||
fill="outline"
|
||||
onClick={() => markDone(item)}
|
||||
>
|
||||
已跟进
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={() => deleteReminder(item)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{item.content}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{(item.customerName || item.customerId) && (
|
||||
<span>学员:{item.customerName || item.customerId}</span>
|
||||
)}
|
||||
<span style={{
|
||||
padding: '1px 8px',
|
||||
borderRadius: 10,
|
||||
fontSize: 11,
|
||||
color: status.color,
|
||||
background: status.bg
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
{item.status === 'NOTIFIED' && item.notifyTime && (
|
||||
<span>推送于 {formatTimeDisplay(item.notifyTime)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#1677ff' }}>
|
||||
{formatTimeDisplay(item.remindTime || (item as any).remind_time)}
|
||||
</span>
|
||||
</List.Item>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReminderApp
|
||||
@ -421,7 +421,7 @@ function SummaryApp() {
|
||||
if (!rtFullRef.current.trim()) {
|
||||
stopRtTimer()
|
||||
setRtStreaming(false)
|
||||
setRtError('接口未返回总结内容')
|
||||
setRtError('不存在今日消息,无法总结')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
|
||||
10
frontend/sidebar/src/reminder.tsx
Normal file
10
frontend/sidebar/src/reminder.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import ReminderApp from './ReminderApp'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ReminderApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@ -26,7 +26,8 @@ export default defineConfig({
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
summary: resolve(__dirname, 'summary.html'),
|
||||
'summary-search': resolve(__dirname, 'summary-search.html')
|
||||
'summary-search': resolve(__dirname, 'summary-search.html'),
|
||||
reminder: resolve(__dirname, 'reminder.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
init.sql
20
init.sql
@ -823,3 +823,23 @@ INSERT INTO utterances (
|
||||
'["积极","感兴趣"]',
|
||||
'["跟进维护","活动邀约","价值输出"]',
|
||||
98, 76, 0.7755, 'MANUAL', 'admin');
|
||||
|
||||
-- 备忘提醒跟进表
|
||||
-- 咨询老师在 sidebar 创建提醒,到点后通过企微自建应用 message/send 推送给老师
|
||||
CREATE TABLE IF NOT EXISTS `reminders` (
|
||||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||||
`staff_id` VARCHAR(128) NOT NULL COMMENT '咨询老师企微userid',
|
||||
`staff_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '咨询老师姓名',
|
||||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员external_userid',
|
||||
`customer_name` VARCHAR(256) NOT NULL DEFAULT '' COMMENT '学员姓名',
|
||||
`remind_time` DATETIME NOT NULL COMMENT '提醒时间(精确到小时,分秒为0)',
|
||||
`content` VARCHAR(1024) NOT NULL COMMENT '提醒文字',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待推送 NOTIFIED已推送 FAILED推送失败',
|
||||
`notify_time` DATETIME NULL COMMENT '实际推送时间',
|
||||
`retry_count` INT(11) NOT NULL DEFAULT 0 COMMENT '推送重试次数',
|
||||
`deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_staff_time` (`staff_id`, `remind_time`) COMMENT '老师+提醒时间',
|
||||
INDEX `idx_status_time` (`status`, `remind_time`) COMMENT '状态+提醒时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='备忘提醒跟进表';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user