diff --git a/add_reminders.sql b/add_reminders.sql new file mode 100644 index 0000000..b868979 --- /dev/null +++ b/add_reminders.sql @@ -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='备忘提醒跟进表'; diff --git a/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java b/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java index e373d8e..91f5f07 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java @@ -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 text = new HashMap<>(); + text.put("content", content); + Map 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> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + Map 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; + } + } + /** * 获取员工详情 */ diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ReminderController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ReminderController.java new file mode 100644 index 0000000..46e8693 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ReminderController.java @@ -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 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> 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 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 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> 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 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; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/Reminder.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/Reminder.java new file mode 100644 index 0000000..fd8f93b --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/Reminder.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ReminderMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ReminderMapper.java new file mode 100644 index 0000000..92f6a5d --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ReminderMapper.java @@ -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 { +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderNotifyJob.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderNotifyJob.java new file mode 100644 index 0000000..7bb9920 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderNotifyJob.java @@ -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 wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Reminder::getStatus, ReminderService.STATUS_PENDING) + .le(Reminder::getRemindTime, now) + .orderByAsc(Reminder::getRemindTime) + .last("LIMIT " + BATCH_SIZE); + List 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); + } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderService.java new file mode 100644 index 0000000..f97a17e --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ReminderService.java @@ -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 listToday(String staffId) { + LocalDate today = LocalDate.now(ZONE); + LocalDateTime start = today.atStartOfDay(); + LocalDateTime end = today.atTime(23, 59, 59); + + LambdaQueryWrapper 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 adminPage(int page, int size, String staffName, String customerName, + String status, String date) { + LambdaQueryWrapper 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 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 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 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; + } +} diff --git a/backend/archive-service/src/main/resources/application.yml b/backend/archive-service/src/main/resources/application.yml index 975ed3b..484e5ba 100644 --- a/backend/archive-service/src/main/resources/application.yml +++ b/backend/archive-service/src/main/resources/application.yml @@ -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: diff --git a/frontend/admin/src/App.tsx b/frontend/admin/src/App.tsx index ddb7e6b..faf1748 100644 --- a/frontend/admin/src/App.tsx +++ b/frontend/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/admin/src/components/Layout.tsx b/frontend/admin/src/components/Layout.tsx index 1fb60a5..a23ee7b 100644 --- a/frontend/admin/src/components/Layout.tsx +++ b/frontend/admin/src/components/Layout.tsx @@ -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: , label: '大模型管理' }, { key: '/cos-config', icon: , label: 'COS配置' }, { key: '/chat-mining', icon: , label: '会话挖掘' }, + { key: '/reminders', icon: , label: '提醒管理' }, ] const handleLogout = () => { diff --git a/frontend/admin/src/pages/ReminderList.tsx b/frontend/admin/src/pages/ReminderList.tsx new file mode 100644 index 0000000..92de89f --- /dev/null +++ b/frontend/admin/src/pages/ReminderList.tsx @@ -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 = { + PENDING: { color: 'blue', text: '待推送' }, + NOTIFIED: { color: 'green', text: '已推送' }, + FAILED: { color: 'red', text: '推送失败' }, + DONE: { color: 'default', text: '已跟进' }, +} + +export default function ReminderList() { + const [data, setData] = useState([]) + 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(undefined) + const [date, setDate] = useState(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.text} + }, + }, + { 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) => ( + handleDelete(record.id)}> + + + ), + }, + ] + + return ( +
+ + + setStaffName(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 160 }} + allowClear + /> + setCustomerName(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 160 }} + allowClear + /> +