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.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@ -28,6 +32,9 @@ public class WeComApiClient {
|
|||||||
@Value("${wecom.agent.secret:}")
|
@Value("${wecom.agent.secret:}")
|
||||||
private String agentSecret;
|
private String agentSecret;
|
||||||
|
|
||||||
|
@Value("${wecom.agent.agent-id:1000021}")
|
||||||
|
private String agentId;
|
||||||
|
|
||||||
@Value("${wecom.contact.secret:}")
|
@Value("${wecom.contact.secret:}")
|
||||||
private String contactSecret;
|
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
|
corp-id: wwd483c2fba24ae30a
|
||||||
agent:
|
agent:
|
||||||
secret: ${WECOM_AGENT_SECRET:}
|
secret: ${WECOM_AGENT_SECRET:}
|
||||||
|
agent-id: ${WECOM_AGENT_ID:1000021}
|
||||||
contact:
|
contact:
|
||||||
secret: ${WECOM_CONTACT_SECRET:}
|
secret: ${WECOM_CONTACT_SECRET:}
|
||||||
archive:
|
archive:
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import MessageSearch from './pages/MessageSearch'
|
|||||||
import LlmModelList from './pages/LlmModelList'
|
import LlmModelList from './pages/LlmModelList'
|
||||||
import ChatMining from './pages/ChatMining'
|
import ChatMining from './pages/ChatMining'
|
||||||
import CosConfig from './pages/CosConfig'
|
import CosConfig from './pages/CosConfig'
|
||||||
|
import ReminderList from './pages/ReminderList'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -37,6 +38,7 @@ function App() {
|
|||||||
<Route path="llm-models" element={<LlmModelList />} />
|
<Route path="llm-models" element={<LlmModelList />} />
|
||||||
<Route path="chat-mining" element={<ChatMining />} />
|
<Route path="chat-mining" element={<ChatMining />} />
|
||||||
<Route path="cos-config" element={<CosConfig />} />
|
<Route path="cos-config" element={<CosConfig />} />
|
||||||
|
<Route path="reminders" element={<ReminderList />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd'
|
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'
|
import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification'
|
||||||
|
|
||||||
const { Header, Sider, Content } = AntLayout
|
const { Header, Sider, Content } = AntLayout
|
||||||
@ -79,6 +79,7 @@ export default function Layout() {
|
|||||||
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
|
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
|
||||||
{ key: '/cos-config', icon: <CloudUploadOutlined />, label: 'COS配置' },
|
{ key: '/cos-config', icon: <CloudUploadOutlined />, label: 'COS配置' },
|
||||||
{ key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' },
|
{ key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' },
|
||||||
|
{ key: '/reminders', icon: <BellOutlined />, label: '提醒管理' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleLogout = () => {
|
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;
|
try_files /summary-search.html =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 跟进提醒独立页面
|
||||||
|
location = /reminder {
|
||||||
|
try_files /reminder.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
# 静态资源
|
# 静态资源
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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()) {
|
if (!rtFullRef.current.trim()) {
|
||||||
stopRtTimer()
|
stopRtTimer()
|
||||||
setRtStreaming(false)
|
setRtStreaming(false)
|
||||||
setRtError('接口未返回总结内容')
|
setRtError('不存在今日消息,无法总结')
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') return
|
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: {
|
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')
|
'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');
|
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