feat: 新增备忘提醒跟进功能

本次提交完成了备忘提醒全流程功能开发:
1. 新增reminders数据库表与初始化SQL脚本
2. 后端实现提醒CRUD、定时推送任务、企微消息推送
3. 侧边栏新增提醒页面,支持创建/查看/删除/标记已跟进提醒
4. 管理后台新增提醒管理页面,支持多条件查询与删除
5. 优化了会话总结的错误提示文案
6. 补充了企微应用agent-id配置项
This commit is contained in:
jqb 2026-08-11 11:54:05 +08:00
parent 72c7d557eb
commit 9c3268acdb
18 changed files with 1368 additions and 3 deletions

19
add_reminders.sql Normal file
View 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='备忘提醒跟进表';

View File

@ -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;
}
}
/**
* 获取员工详情
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = () => {

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

View File

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

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>跟进提醒</title>
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/reminder.tsx"></script>
</body>
</html>

View 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

View File

@ -421,7 +421,7 @@ function SummaryApp() {
if (!rtFullRef.current.trim()) {
stopRtTimer()
setRtStreaming(false)
setRtError('接口未返回总结内容')
setRtError('不存在今日消息,无法总结')
}
} catch (e: any) {
if (e?.name === 'AbortError') return

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

View File

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

View File

@ -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='备忘提醒跟进表';