完善日程提醒功能

This commit is contained in:
扫地僧 2026-06-18 00:55:14 +08:00
parent 6ee1f26124
commit bd30b68766
16 changed files with 1621 additions and 297 deletions

View File

@ -0,0 +1,109 @@
package controllers
import (
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
type ApiReminderController struct {
beego.Controller
}
// AckReminder GET /api/schedule/reminder/ack
// 邮件/Bark 客户端访问此接口进行提醒确认
func (c *ApiReminderController) AckReminder() {
token := c.GetString("token")
if token == "" {
c.Ctx.Output.SetStatus(400)
_ = c.Ctx.Output.Body([]byte("Invalid request: missing token"))
return
}
var reminder models.PlatformScheduleReminder
err := models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("ack_token", token).
Filter("is_deleted", 0).
One(&reminder)
if err != nil {
c.Ctx.Output.SetStatus(404)
_ = c.Ctx.Output.Body([]byte("Error: reminder task not found or token has expired"))
return
}
if reminder.AckStatus == 1 {
// 已经确认过了,直接显示已确认成功的 HTML
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
_ = c.Ctx.Output.Body([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>确认收到提醒</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
h2 { color: #67C23A; }
</style>
</head>
<body>
<div class="card">
<h2>提示</h2>
<p>该日程提醒在此之前已确认过了</p>
<p style="color: #909399; font-size: 14px;">无需重复点击感谢您的使用</p>
</div>
</body>
</html>
`))
return
}
// 更新确认状态为已确认,置 remind_status 为已结束(2)
now := time.Now()
reminder.AckStatus = 1
reminder.AckTime = &now
reminder.RemindStatus = 2
reminder.UpdateTime = now
_, err = models.Orm.Update(&reminder, "AckStatus", "AckTime", "RemindStatus", "UpdateTime")
if err != nil {
c.Ctx.Output.SetStatus(500)
_ = c.Ctx.Output.Body([]byte("Database error, please try again later"))
return
}
// 统一关闭该日程下的所有其他待提醒/提醒中渠道,防止重复打扰
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("ScheduleID", reminder.ScheduleID).
Filter("RemindStatus__in", 0, 1).
Update(map[string]interface{}{
"RemindStatus": int8(2),
"UpdateTime": now,
})
// 成功确认
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
_ = c.Ctx.Output.Body([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>确认成功</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
h2 { color: #67C23A; }
</style>
</head>
<body>
<div class="card">
<h2>确认成功</h2>
<p>您已成功确认收到该日程提醒</p>
<p style="color: #909399; font-size: 14px;">系统已停止向您重复推送感谢您的配合</p>
</div>
</body>
</html>
`))
}

View File

@ -0,0 +1,631 @@
package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
beego "github.com/beego/beego/v2/server/web"
)
type PlatformReminderController struct {
beego.Controller
}
func (c *PlatformReminderController) platformClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformReminderController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformReminderController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// generateToken 生成一个随机的 ack_token
func generateToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
type reminderFormPayload struct {
Title string `json:"title"`
Content string `json:"content"`
ScheduleTime string `json:"schedule_time"`
RemindChannels []string `json:"remind_channels"` // EMAIL, BARK, SMS, SITE_MSG
AdvanceMinutes int `json:"advance_minutes"`
RepeatIntervalMinutes int `json:"repeat_interval_minutes"`
MaxSendCount int `json:"max_send_count"`
ReceiverUserID uint64 `json:"receiver_user_id"`
ReceiverTargets map[string]string `json:"receiver_targets"` // "SMS": "1380...", "EMAIL": "...", "BARK": "..."
}
// GetReminderList GET /platform/reminder/list
func (c *PlatformReminderController) GetReminderList() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
// 联表获取日程及提醒信息
var schedules []models.PlatformSchedule
qs := models.Orm.QueryTable(new(models.PlatformSchedule))
total, _ := qs.Count()
_, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules)
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(schedules))
for _, s := range schedules {
// 查询该日程关联的所有提醒记录
var reminders []models.PlatformScheduleReminder
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", s.ID).
Filter("is_deleted", 0).
All(&reminders)
channels := make([]string, 0, len(reminders))
isFinished := true
if len(reminders) == 0 {
isFinished = false
} else {
for _, r := range reminders {
channels = append(channels, r.RemindChannel)
if r.RemindStatus != 2 {
isFinished = false
}
}
}
item := map[string]interface{}{
"id": s.ID,
"title": s.Title,
"content": s.Content,
"schedule_time": s.ScheduleTime.Format("2006-01-02 15:04:05"),
"remind_channels": channels,
"user_id": s.UserID,
"is_finished": isFinished,
}
if len(reminders) > 0 {
first := reminders[0]
item["advance_minutes"] = first.AdvanceMinutes
item["repeat_interval_minutes"] = first.RepeatIntervalMinutes
item["max_send_count"] = first.MaxSendCount
item["receiver_user_id"] = first.ReceiverUserID
}
list = append(list, item)
}
c.ok(map[string]interface{}{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// GetReminderDetail GET /platform/reminder/:id
func (c *PlatformReminderController) GetReminderDetail() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.jsonErr(400, 400, "无效的ID")
return
}
var schedule models.PlatformSchedule
err := models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).One(&schedule)
if err != nil {
c.jsonErr(404, 404, "日程未找到")
return
}
var reminders []models.PlatformScheduleReminder
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", schedule.ID).
Filter("is_deleted", 0).
All(&reminders)
channels := make([]string, 0, len(reminders))
targets := make(map[string]string)
var first models.PlatformScheduleReminder
for _, r := range reminders {
channels = append(channels, r.RemindChannel)
if r.ReceiverTarget != nil {
targets[r.RemindChannel] = *r.ReceiverTarget
}
first = r
}
isFinished := true
if len(reminders) == 0 {
isFinished = false
} else {
for _, r := range reminders {
if r.RemindStatus != 2 {
isFinished = false
break
}
}
}
data := map[string]interface{}{
"id": schedule.ID,
"title": schedule.Title,
"content": schedule.Content,
"schedule_time": schedule.ScheduleTime.Format("2006-01-02 15:04:05"),
"remind_channels": channels,
"receiver_targets": targets,
"is_finished": isFinished,
}
if first.ID > 0 {
data["advance_minutes"] = first.AdvanceMinutes
data["repeat_interval_minutes"] = first.RepeatIntervalMinutes
data["max_send_count"] = first.MaxSendCount
data["receiver_user_id"] = first.ReceiverUserID
}
c.ok(data)
}
// CreateReminder POST /platform/reminder
func (c *PlatformReminderController) CreateReminder() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p reminderFormPayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ScheduleTime) == "" {
c.jsonErr(400, 400, "日程发生时间不能为空")
return
}
schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local)
if err != nil {
c.jsonErr(400, 400, "日程时间格式不合法,支持 YYYY-MM-DD HH:mm:ss")
return
}
// 1. 插入日程主表
schedule := models.PlatformSchedule{
Title: "日程提醒",
Content: p.Content,
ScheduleTime: schedTime,
UserID: uint64(claims.UserID),
}
schedID, err := models.Orm.Insert(&schedule)
if err != nil {
c.jsonErr(500, 500, "保存日程失败: "+err.Error())
return
}
// 2. 根据选中的渠道循环创建提醒
for _, ch := range p.RemindChannels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" {
continue
}
targetVal := p.ReceiverTargets[ch]
var target *string
if targetVal != "" {
target = &targetVal
}
// 计算首次发送时间
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
reminder := models.PlatformScheduleReminder{
ScheduleID: uint64(schedID),
RemindChannel: ch,
AdvanceMinutes: p.AdvanceMinutes,
NextRemindTime: firstSendTime,
ReceiverUserID: uint64(claims.UserID), // 谁创建的就发给谁
ReceiverTarget: target,
RemindStatus: 0, // 待提醒
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if ch == "EMAIL" || ch == "BARK" {
token := generateToken()
reminder.AckToken = &token
reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes
reminder.MaxSendCount = p.MaxSendCount
if reminder.MaxSendCount <= 0 {
reminder.MaxSendCount = 1
}
} else {
// SMS 或 SITE_MSG
reminder.RepeatIntervalMinutes = 0
reminder.MaxSendCount = 1
}
_, err = models.Orm.Insert(&reminder)
if err != nil {
c.jsonErr(500, 500, "创建提醒失败: "+err.Error())
return
}
}
c.ok(map[string]interface{}{"schedule_id": schedID})
}
// UpdateReminder PUT /platform/reminder/:id
func (c *PlatformReminderController) UpdateReminder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.jsonErr(400, 400, "无效的ID")
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p reminderFormPayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local)
if err != nil {
c.jsonErr(400, 400, "日程时间格式不合法")
return
}
// 1. 更新日程详情
var schedule models.PlatformSchedule
err = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).One(&schedule)
if err != nil {
c.jsonErr(404, 404, "日程未找到")
return
}
// 检查是否所有关联的提醒都已结束
var reminders []models.PlatformScheduleReminder
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", id).
Filter("is_deleted", 0).
All(&reminders)
isFinished := true
if len(reminders) == 0 {
isFinished = false
} else {
for _, r := range reminders {
if r.RemindStatus != 2 {
isFinished = false
break
}
}
}
if isFinished {
c.jsonErr(400, 400, "该日程提醒已全部结束,无法编辑")
return
}
schedule.Title = "日程提醒"
schedule.Content = p.Content
schedule.ScheduleTime = schedTime
_, err = models.Orm.Update(&schedule, "Title", "Content", "ScheduleTime")
if err != nil {
c.jsonErr(500, 500, "更新失败")
return
}
// 2. 软删除原本的所有提醒
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", id).
Update(map[string]interface{}{
"IsDeleted": 1,
"UpdateTime": time.Now(),
})
// 3. 重新建立提醒
for _, ch := range p.RemindChannels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" {
continue
}
targetVal := p.ReceiverTargets[ch]
var target *string
if targetVal != "" {
target = &targetVal
}
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
reminder := models.PlatformScheduleReminder{
ScheduleID: id,
RemindChannel: ch,
AdvanceMinutes: p.AdvanceMinutes,
NextRemindTime: firstSendTime,
ReceiverUserID: schedule.UserID, // 谁创建的就发给谁
ReceiverTarget: target,
RemindStatus: 0,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if ch == "EMAIL" || ch == "BARK" {
token := generateToken()
reminder.AckToken = &token
reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes
reminder.MaxSendCount = p.MaxSendCount
if reminder.MaxSendCount <= 0 {
reminder.MaxSendCount = 1
}
} else {
reminder.RepeatIntervalMinutes = 0
reminder.MaxSendCount = 1
}
_, err = models.Orm.Insert(&reminder)
if err != nil {
c.jsonErr(500, 500, "重新创建提醒失败")
return
}
}
c.ok(nil)
}
// DeleteReminder DELETE /platform/reminder/:id
func (c *PlatformReminderController) DeleteReminder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.jsonErr(400, 400, "无效的ID")
return
}
// 检查是否所有关联的提醒都已结束
var reminders []models.PlatformScheduleReminder
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", id).
Filter("is_deleted", 0).
All(&reminders)
isFinished := true
if len(reminders) == 0 {
isFinished = false
} else {
for _, r := range reminders {
if r.RemindStatus != 2 {
isFinished = false
break
}
}
}
if isFinished {
c.jsonErr(400, 400, "该日程提醒已全部结束,无法删除")
return
}
// 软删除日程
// 这里的 id 既可以是主表的 id也可以是日程的 id
// 我们如果是管理页面,都是基于日程维度的,所以这里 id 指代 schedule_id
_, err := models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).Delete()
if err == nil {
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", id).
Update(map[string]interface{}{
"IsDeleted": 1,
"UpdateTime": time.Now(),
})
}
c.ok(nil)
}
type reminderBatchDeletePayload struct {
Ids []uint64 `json:"ids"`
}
// BatchDeleteReminder POST /platform/reminder/batchDelete
func (c *PlatformReminderController) BatchDeleteReminder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p reminderBatchDeletePayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if len(p.Ids) == 0 {
c.ok(nil)
return
}
// 检查选中的日程是否有任何一个是全部结束的,防误操作
for _, scheduleID := range p.Ids {
var reminders []models.PlatformScheduleReminder
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id", scheduleID).
Filter("is_deleted", 0).
All(&reminders)
isFinished := true
if len(reminders) == 0 {
isFinished = false
} else {
for _, r := range reminders {
if r.RemindStatus != 2 {
isFinished = false
break
}
}
}
if isFinished {
c.jsonErr(400, 400, fmt.Sprintf("选中的日程ID %d 的提醒已全部结束,无法删除", scheduleID))
return
}
}
// 批量软删除
_, _ = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id__in", p.Ids).Delete()
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("schedule_id__in", p.Ids).
Update(map[string]interface{}{
"IsDeleted": 1,
"UpdateTime": time.Now(),
})
c.ok(nil)
}
type reminderTestPayload struct {
Title string `json:"title"`
Content string `json:"content"`
RemindChannels []string `json:"remind_channels"`
}
// TestReminder POST /platform/reminder/test
func (c *PlatformReminderController) TestReminder() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p reminderTestPayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.Title) == "" {
p.Title = "测试提醒"
}
if strings.TrimSpace(p.Content) == "" {
p.Content = "这是一条验证日程提醒配置的测试通知。"
}
senders := map[string]services.ReminderSender{
"SMS": &services.SMSSender{},
"EMAIL": &services.EmailSender{},
"BARK": &services.BarkSender{},
"SITE_MSG": &services.SiteMsgSender{},
}
type TestResult struct {
Channel string `json:"channel"`
Success bool `json:"success"`
Msg string `json:"msg"`
}
results := make([]TestResult, 0)
for _, ch := range p.RemindChannels {
ch = strings.ToUpper(strings.TrimSpace(ch))
sender, ok := senders[ch]
if !ok {
results = append(results, TestResult{Channel: ch, Success: false, Msg: "不支持的提醒渠道"})
continue
}
dummyToken := "test-token-for-verification"
reminder := &models.PlatformScheduleReminder{
RemindChannel: ch,
ReceiverUserID: uint64(claims.UserID),
AckToken: &dummyToken,
}
success, sendErr := sender.Send(context.Background(), reminder, "[测试]"+p.Title, p.Content)
msg := "发送成功"
if !success {
msg = "发送失败"
if sendErr != nil {
msg = sendErr.Error()
}
}
results = append(results, TestResult{Channel: ch, Success: success, Msg: msg})
}
c.ok(results)
}

View File

@ -2,6 +2,7 @@ package main
import ( import (
"server/models" "server/models"
"server/services"
_ "server/routers" _ "server/routers"
"server/version" "server/version"
@ -21,5 +22,8 @@ func main() {
// 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件 // 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件
beego.SetStaticPath("/uploads", "uploads") beego.SetStaticPath("/uploads", "uploads")
// 启动日程提醒定时任务
services.StartReminderScheduler(make(chan struct{}))
beego.Run() beego.Run()
} }

View File

@ -69,6 +69,10 @@ func Init(_ string) {
new(SystemNormalSetting), new(SystemNormalSetting),
new(PlatformNormalSetting), new(PlatformNormalSetting),
new(BackendNormalSetting), new(BackendNormalSetting),
new(PlatformSchedule),
new(PlatformScheduleReminder),
new(PlatformScheduleReminderSendLog),
) )
// 创建全局 Ormer // 创建全局 Ormer

View File

@ -0,0 +1,55 @@
package models
import "time"
// PlatformSchedule 日程主表: yz_platform_schedule
type PlatformSchedule struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Title string `orm:"column(title);size(255)" json:"title"`
Content string `orm:"column(content);type(text)" json:"content"`
ScheduleTime time.Time `orm:"column(schedule_time);type(datetime)" json:"schedule_time"`
UserID uint64 `orm:"column(user_id)" json:"user_id"`
}
func (m *PlatformSchedule) TableName() string {
return "yz_platform_schedule"
}
// PlatformScheduleReminder 日程提醒主表: yz_platform_schedule_reminder
type PlatformScheduleReminder struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
ScheduleID uint64 `orm:"column(schedule_id)" json:"schedule_id"`
RemindChannel string `orm:"column(remind_channel);size(20)" json:"remind_channel"` // SMS/EMAIL/BARK/SITE_MSG
AdvanceMinutes int `orm:"column(advance_minutes);default(0)" json:"advance_minutes"`
RepeatIntervalMinutes int `orm:"column(repeat_interval_minutes);default(0)" json:"repeat_interval_minutes"`
NextRemindTime time.Time `orm:"column(next_remind_time);type(datetime)" json:"next_remind_time"`
SendCount int `orm:"column(send_count);default(0)" json:"send_count"`
MaxSendCount int `orm:"column(max_send_count);default(1)" json:"max_send_count"`
AckToken *string `orm:"column(ack_token);size(64);null" json:"ack_token"`
AckStatus int8 `orm:"column(ack_status);default(0)" json:"ack_status"` // 0-未确认 1-已确认
AckTime *time.Time `orm:"column(ack_time);type(datetime);null" json:"ack_time"`
ReceiverUserID uint64 `orm:"column(receiver_user_id)" json:"receiver_user_id"`
ReceiverTarget *string `orm:"column(receiver_target);size(255);null" json:"receiver_target"`
RemindStatus int8 `orm:"column(remind_status);default(0)" json:"remind_status"` // 0-待提醒 1-提醒中 2-已结束
ScanLock string `orm:"column(scan_lock);size(64);default('')" json:"scan_lock"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
}
func (m *PlatformScheduleReminder) TableName() string {
return "yz_platform_schedule_reminder"
}
// PlatformScheduleReminderSendLog 提醒实际发送流水: yz_platform_schedule_reminder_send_log
type PlatformScheduleReminderSendLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
ReminderID uint64 `orm:"column(reminder_id)" json:"reminder_id"`
SendTime time.Time `orm:"column(send_time);type(datetime)" json:"send_time"`
SendResult int8 `orm:"column(send_result)" json:"send_result"` // 0-失败 1-成功
FailReason *string `orm:"column(fail_reason);size(255);null" json:"fail_reason"`
}
func (m *PlatformScheduleReminderSendLog) TableName() string {
return "yz_platform_schedule_reminder_send_log"
}

View File

@ -30,4 +30,7 @@ func Register() {
// 对外提卡接口(无需登录) // 对外提卡接口(无需登录)
// GET /api/getcard?type=xianyu&module=cursor&data_type=tk // GET /api/getcard?type=xianyu&module=cursor&data_type=tk
beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard") beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard")
// 日程提醒确认接口(无需登录)
beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder")
} }

View File

@ -255,4 +255,11 @@ func Register() {
beego.Router("/platform/notebook/create", &controllers.PlatformNotebookController{}, "post:Create") beego.Router("/platform/notebook/create", &controllers.PlatformNotebookController{}, "post:Create")
beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update") beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update")
beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete") beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete")
// 日程提醒管理
beego.Router("/platform/reminder/list", &controllers.PlatformReminderController{}, "get:GetReminderList")
beego.Router("/platform/reminder/test", &controllers.PlatformReminderController{}, "post:TestReminder")
beego.Router("/platform/reminder/:id", &controllers.PlatformReminderController{}, "get:GetReminderDetail;put:UpdateReminder;delete:DeleteReminder")
beego.Router("/platform/reminder", &controllers.PlatformReminderController{}, "post:CreateReminder")
beego.Router("/platform/reminder/batchDelete", &controllers.PlatformReminderController{}, "post:BatchDeleteReminder")
} }

View File

@ -0,0 +1,371 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/models"
)
// ReminderSender 提醒发送接口
type ReminderSender interface {
Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (success bool, err error)
}
// SMSSender 短信发送实现
type SMSSender struct{}
func (s *SMSSender) Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (bool, error) {
backendURL, apiKey, err := getDefaultSystemSMSConfig()
if err != nil {
return false, err
}
phone := ""
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
phone = *reminder.ReceiverTarget
} else {
var user models.AdminUser
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Phone != nil {
phone = *user.Phone
}
}
if phone == "" {
return false, fmt.Errorf("未配置手机号")
}
enqueueURL := strings.TrimRight(backendURL, "/") + "/api/v1/business/outbound-tasks"
payload := map[string]interface{}{
"phone": phone,
"content": title + ": " + content,
}
bs, _ := json.Marshal(payload)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", enqueueURL, bytes.NewReader(bs))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", apiKey)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("网关返回HTTP状态码: %d, 返回内容: %s", resp.StatusCode, string(bodyBytes))
}
return true, nil
}
// EmailSender 邮件发送实现
type EmailSender struct{}
func (s *EmailSender) Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (bool, error) {
emails, err := ListSystemEmails()
if err != nil || len(emails) == 0 {
return false, fmt.Errorf("未配置系统邮箱")
}
emailCfg := emails[0]
if emailCfg.FromAddress == "" || emailCfg.Host == "" {
return false, fmt.Errorf("未配置系统邮箱")
}
toEmail := ""
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
toEmail = *reminder.ReceiverTarget
} else {
var user models.AdminUser
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Email != nil {
toEmail = *user.Email
}
}
if toEmail == "" {
return false, fmt.Errorf("未配置收件邮箱")
}
sysDomain := models.GetPlatformSettingValue("system_domain", "http://127.0.0.1:8080")
ackToken := ""
if reminder.AckToken != nil {
ackToken = *reminder.AckToken
}
// 构造 HTML 邮件
htmlBody := fmt.Sprintf(`
<div style="font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 5px; max-width: 600px; margin: 0 auto;">
<h2 style="color: #409EFF; margin-bottom: 20px;">日程提醒%s</h2>
<p style="font-size: 16px; line-height: 1.6; color: #333;">%s</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
`, title, content)
if ackToken != "" {
ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken)
htmlBody += fmt.Sprintf(`
<div style="text-align: center; margin-top: 30px;">
<a href="%s" target="_blank" style="background-color: #409EFF; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;">
收到确认此提醒
</a>
</div>
<p style="font-size: 12px; color: #999; text-align: center; margin-top: 15px;">确认收到后系统将不再向您发送该日程的重复提醒</p>
`, ackURL)
}
htmlBody += "</div>"
cfg := SMTPConfig{
FromAddress: emailCfg.FromAddress,
Host: emailCfg.Host,
Port: emailCfg.Port,
Password: emailCfg.Password,
Encryption: emailCfg.Encryption,
Timeout: emailCfg.Timeout,
}
if emailCfg.FromName != nil {
cfg.FromName = *emailCfg.FromName
}
err = SendHTMLEmailSMTP(cfg, toEmail, title, htmlBody)
if err != nil {
return false, err
}
return true, nil
}
// BarkSender Bark 推送实现
type BarkSender struct{}
func (s *BarkSender) Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (bool, error) {
deviceKey := ""
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
deviceKey = *reminder.ReceiverTarget
} else {
deviceKey = models.GetPlatformSettingValue("bark_device_key", "")
}
if deviceKey == "" {
return false, fmt.Errorf("Bark 设备 Key 未配置")
}
serverURL := models.GetPlatformSettingValue("bark_server_url", "https://api.day.app")
sysDomain := models.GetPlatformSettingValue("system_domain", "http://127.0.0.1:8080")
ackToken := ""
if reminder.AckToken != nil {
ackToken = *reminder.AckToken
}
baseURL := strings.TrimRight(serverURL, "/")
escapedTitle := url.PathEscape(title)
pushContent := content
if ackToken != "" {
pushContent += "\n确认收到请点击→"
}
escapedContent := url.PathEscape(pushContent)
barkURL := fmt.Sprintf("%s/%s/%s/%s", baseURL, deviceKey, escapedTitle, escapedContent)
if ackToken != "" {
ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken)
// Bark 官方推送支持 url 参数
barkURL += "?url=" + url.QueryEscape(ackURL)
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", barkURL, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("Bark返回HTTP状态码: %d, 返回内容: %s", resp.StatusCode, string(bodyBytes))
}
return true, nil
}
// SiteMsgSender 站内信发送实现
type SiteMsgSender struct{}
func (s *SiteMsgSender) Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (bool, error) {
now := time.Now()
msg := &models.SystemReminderList{
Title: title,
Content: content,
SenderID: 0,
SenderType: "system",
ReceiverID: reminder.ReceiverUserID,
ReceiverType: "platform", // 平台端用户
IsRead: 0,
CreateTime: &now,
}
_, err := models.Orm.Insert(msg)
if err != nil {
return false, err
}
return true, nil
}
// generateUUID 生成一个安全的随机 UUID 字符
func generateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
// StartReminderScheduler 启动定时提醒调度器 (1分钟一次的 Ticker)
func StartReminderScheduler(stopChan chan struct{}) {
ticker := time.NewTicker(1 * time.Minute)
go func() {
for {
select {
case <-ticker.C:
scanAndSendReminders()
case <-stopChan:
ticker.Stop()
return
}
}
}()
}
func scanAndSendReminders() {
// 1. 生成唯一扫描批次号用于抢占锁定
scanBatch := generateUUID()
now := time.Now()
// 2. 抢占待处理的数据(乐观锁防并发重复发送)
_, err := models.Orm.Raw(`
UPDATE yz_platform_schedule_reminder
SET scan_lock = ?, update_time = NOW()
WHERE next_remind_time <= ?
AND remind_status IN (0, 1)
AND is_deleted = 0
AND (scan_lock = '' OR scan_lock IS NULL)
`, scanBatch, now).Exec()
if err != nil {
return
}
// 3. 查询自己锁定成功的数据
var list []models.PlatformScheduleReminder
_, err = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("scan_lock", scanBatch).
Filter("remind_status__in", 0, 1).
Filter("is_deleted", 0).
All(&list)
if err != nil || len(list) == 0 {
return
}
// 实例分发发送
senders := map[string]ReminderSender{
"SMS": &SMSSender{},
"EMAIL": &EmailSender{},
"BARK": &BarkSender{},
"SITE_MSG": &SiteMsgSender{},
}
for i := range list {
reminder := &list[i]
// 3.1 获取日程信息(主要拿 ContentTitle 统一为 "日程提醒"
var schedule models.PlatformSchedule
err := models.Orm.QueryTable(new(models.PlatformSchedule)).
Filter("id", reminder.ScheduleID).
One(&schedule)
title := "日程提醒"
content := "您有一个待处理的日程时间已到,请注意查收。"
if err == nil {
content = schedule.Content
}
sender, ok := senders[reminder.RemindChannel]
if !ok {
// 未知渠道,直接强制置为结束
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("id", reminder.ID).
Update(map[string]interface{}{
"remind_status": 2,
"scan_lock": "",
"update_time": time.Now(),
})
continue
}
// 执行发送
ctx := context.Background()
success, sendErr := sender.Send(ctx, reminder, title, content)
// 3.2 记录发送流水日志
sendResult := int8(0)
var failReason *string
if success {
sendResult = 1
} else if sendErr != nil {
errStr := sendErr.Error()
if len(errStr) > 255 {
errStr = errStr[:255]
}
failReason = &errStr
}
logRow := &models.PlatformScheduleReminderSendLog{
ReminderID: reminder.ID,
SendTime: time.Now(),
SendResult: sendResult,
FailReason: failReason,
}
_, _ = models.Orm.Insert(logRow)
// 3.3 根据发送渠道分类更新提醒状态和下一次发送时间
newSendCount := reminder.SendCount + 1
newStatus := reminder.RemindStatus
if reminder.RemindChannel == "SMS" || reminder.RemindChannel == "SITE_MSG" {
// 一次性发送:发送后直接置为结束
newStatus = 2
} else {
// 重复发送渠道 EMAIL / BARK
// 如果还没被 Ack且没有达到 max_send_count继续提醒
if reminder.AckStatus == 0 && newSendCount < reminder.MaxSendCount {
newStatus = 1 // 提醒中
// 更新下次发送时间
reminder.NextRemindTime = time.Now().Add(time.Duration(reminder.RepeatIntervalMinutes) * time.Minute)
} else {
// 达到最大上限或者已 Ack
newStatus = 2
}
}
// 3.4 回写主表记录
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
Filter("id", reminder.ID).
Update(map[string]interface{}{
"SendCount": newSendCount,
"NextRemindTime": reminder.NextRemindTime,
"RemindStatus": newStatus,
"ScanLock": "", // 释放扫描锁
"UpdateTime": time.Now(),
})
}
}

View File

@ -117,6 +117,100 @@ func SendTestEmailSMTP(cfg SMTPConfig, to string) error {
return client.Quit() return client.Quit()
} }
// SendHTMLEmailSMTP 发送一封 HTML 格式邮件
func SendHTMLEmailSMTP(cfg SMTPConfig, to string, subject string, htmlBody string) error {
to = strings.TrimSpace(to)
if to == "" {
return fmt.Errorf("收件人不能为空")
}
if cfg.Host == "" || cfg.FromAddress == "" {
return fmt.Errorf("SMTP 主机或发件人不能为空")
}
if cfg.Port == 0 {
cfg.Port = 465
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30
}
d := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10))
enc := strings.ToLower(strings.TrimSpace(cfg.Encryption))
if enc == "" {
enc = "ssl"
}
var client *smtp.Client
var err error
switch enc {
case "ssl":
conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
if derr != nil {
return fmt.Errorf("连接 SMTP 失败: %w", derr)
}
defer conn.Close()
client, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP 握手失败: %w", err)
}
case "tls":
conn, derr := d.Dial("tcp", addr)
if derr != nil {
return fmt.Errorf("连接 SMTP 失败: %w", derr)
}
defer conn.Close()
client, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP 握手失败: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
_ = client.Close()
return fmt.Errorf("STARTTLS 失败: %w", err)
}
}
case "none":
conn, derr := d.Dial("tcp", addr)
if derr != nil {
return fmt.Errorf("连接 SMTP 失败: %w", derr)
}
defer conn.Close()
client, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP 握手失败: %w", err)
}
default:
return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption)
}
defer func() { _ = client.Close() }()
auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host)
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP 认证失败: %w", err)
}
if err = client.Mail(cfg.FromAddress); err != nil {
return fmt.Errorf("MAIL FROM 失败: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO 失败: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("DATA 失败: %w", err)
}
fromName := strings.TrimSpace(cfg.FromName)
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
formatFromHeader(fromName, cfg.FromAddress), to, subject)
if _, err = wc.Write([]byte(headers + htmlBody)); err != nil {
return fmt.Errorf("写入邮件内容失败: %w", err)
}
if err = wc.Close(); err != nil {
return fmt.Errorf("结束 DATA 失败: %w", err)
}
return client.Quit()
}
func formatFromHeader(name, addr string) string { func formatFromHeader(name, addr string) string {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {

View File

@ -25,6 +25,7 @@ declare module 'vue' {
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']

View File

@ -1,8 +1,11 @@
<script setup> <script setup>
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script> </script>
<template> <template>
<router-view /> <el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template> </template>
<style scoped> <style scoped>

View File

@ -30,7 +30,7 @@ export function createReminder(data) {
export function updateReminder(id, data) { export function updateReminder(id, data) {
return request({ return request({
url: `/platform/reminder/${id}`, url: `/platform/reminder/${id}`,
method: "post", method: "put",
data, data,
}); });
} }
@ -51,3 +51,20 @@ export function batchDeleteReminder(ids) {
data: { ids }, data: { ids },
}); });
} }
/** 测试提醒渠道 */
export function testReminder(data) {
return request({
url: "/platform/reminder/test",
method: "post",
data,
});
}
/** 结束提醒 */
export function finishReminder(id) {
return request({
url: `/platform/reminder/${id}/finish`,
method: "post",
});
}

View File

@ -273,7 +273,7 @@
</el-tab-pane> </el-tab-pane>
<!-- Bark配置 --> <!-- Bark配置 -->
<el-tab-pane name="Bark配置"> <el-tab-pane name="bark">
<template #label> <template #label>
<span class="tab-label-item"> <span class="tab-label-item">
<el-icon><ChatRound /></el-icon> <el-icon><ChatRound /></el-icon>

View File

@ -1,69 +1,49 @@
<template> <template>
<el-drawer v-model="visible" title="提醒详情" size="520px" destroy-on-close> <el-drawer v-model="visible" title="日程提醒详情" size="560px" destroy-on-close>
<div v-if="detail" class="detail-wrap" v-loading="loading"> <div v-if="detail" class="detail-wrap" v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="ID" label-width="110px"> <el-descriptions-item label="ID" label-width="120px">
{{ detail.id }} {{ detail.id }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="状态" label-width="110px"> <el-descriptions-item label="日程发生时间" label-width="120px">
<el-tag :type="detail.status === 1 ? 'success' : 'info'" size="small"> {{ detail.schedule_time || '—' }}
{{ detail.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="标题" :span="2" label-width="110px"> <el-descriptions-item label="日程标题" :span="2" label-width="120px">
{{ detail.title }} {{ detail.title }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="类型" label-width="110px"> <el-descriptions-item label="提醒渠道" :span="2" label-width="120px">
<el-tag :type="typeTagType(detail.type)" size="small"> <div class="channel-tags">
{{ typeText(detail.type) }} <el-tag
</el-tag> v-for="ch in (detail.remind_channels || [])"
</el-descriptions-item> :key="ch"
<el-descriptions-item label="接收范围" label-width="110px"> :type="channelTagType(ch)"
<el-tag type="info" size="small">{{ targetTypeText(detail.targetType) }}</el-tag> size="small"
class="ch-tag"
>
{{ channelText(ch) }}
</el-tag>
<span v-if="!(detail.remind_channels?.length)">无渠道</span>
</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item <el-descriptions-item label="提前提醒时间" :span="2" label-width="120px">
v-if="[2, 3].includes(detail.targetType)" <el-tag type="info" size="small">提前 {{ detail.advance_minutes ?? 0 }} 分钟</el-tag>
label="接收 ID"
:span="2"
label-width="110px"
>
{{ detail.targetIds || '—' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="提醒时间" label-width="110px"> <template v-if="hasRepeatChannel">
{{ detail.remindTime || '—' }} <el-descriptions-item label="重复间隔" label-width="120px">
</el-descriptions-item> {{ detail.repeat_interval_minutes || 0 }} 分钟
<el-descriptions-item label="是否重复" label-width="110px"> </el-descriptions-item>
<el-tag :type="detail.isRepeat === 1 ? 'warning' : 'info'" size="small"> <el-descriptions-item label="最大发送次数" label-width="120px">
{{ detail.isRepeat === 1 ? '是' : '否' }} {{ detail.max_send_count || 1 }}
</el-tag> </el-descriptions-item>
</el-descriptions-item> </template>
<el-descriptions-item <el-descriptions-item label="日程内容" :span="2" label-width="120px">
v-if="detail.isRepeat === 1"
label="重复周期"
label-width="110px"
>
{{ repeatCycleText(detail.repeatCycle) }}
</el-descriptions-item>
<el-descriptions-item label="内容" :span="2" label-width="110px">
<div class="content-text">{{ detail.content || '—' }}</div> <div class="content-text">{{ detail.content || '—' }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="detail.remark" label="备注" :span="2" label-width="110px">
<div class="content-text">{{ detail.remark }}</div>
</el-descriptions-item>
<el-descriptions-item label="创建时间" label-width="110px">
{{ detail.createTime || '—' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" label-width="110px">
{{ detail.updateTime || '—' }}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
@ -82,7 +62,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getReminderDetail } from '@/api/reminder' import { getReminderDetail } from '@/api/reminder'
@ -90,26 +70,32 @@ const visible = ref(false)
const loading = ref(false) const loading = ref(false)
const detail = ref(null) const detail = ref(null)
function typeText(type) { const channelTextMap = {
const map = { 1: '系统通知', 2: '待办提醒', 3: '活动提醒', 4: '自定义' } SMS: '短信',
return map[type] ?? '未知' EMAIL: '邮件',
BARK: 'Bark 推送',
SITE_MSG: '站内信',
} }
function typeTagType(type) { const channelColorMap = {
const map = { 1: 'primary', 2: 'warning', 3: 'success', 4: '' } SMS: 'success',
return map[type] ?? 'info' EMAIL: 'warning',
BARK: 'danger',
SITE_MSG: 'primary',
} }
function targetTypeText(targetType) { function channelText(ch) {
const map = { 1: '全体用户', 2: '指定用户', 3: '指定角色', 4: '仅管理员' } return channelTextMap[ch] || ch
return map[targetType] ?? '—'
} }
function repeatCycleText(cycle) { function channelTagType(ch) {
const map = { daily: '每天', weekly: '每周', monthly: '每月' } return channelColorMap[ch] || 'info'
return map[cycle] ?? cycle ?? '—'
} }
const hasRepeatChannel = computed(() => {
return detail.value?.remind_channels?.some(ch => ['EMAIL', 'BARK'].includes(ch))
})
async function open(id) { async function open(id) {
detail.value = null detail.value = null
visible.value = true visible.value = true
@ -145,4 +131,10 @@ defineExpose({ open })
.empty-wrap { .empty-wrap {
padding: 40px 20px; padding: 40px 20px;
} }
.channel-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style> </style>

View File

@ -1,8 +1,8 @@
<template> <template>
<el-drawer <el-drawer
v-model="visible" v-model="visible"
:title="isAdd ? '新增提醒' : '编辑提醒'" :title="isAdd ? '新增日程提醒' : '编辑日程提醒'"
size="520px" size="560px"
destroy-on-close destroy-on-close
@closed="onClosed" @closed="onClosed"
> >
@ -10,174 +10,144 @@
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="rules"
label-width="100px" label-width="120px"
v-loading="loading" v-loading="loading"
label-position="right"
class="reminder-form"
> >
<el-form-item label="标题" prop="title"> <el-form-item label="日程内容" prop="content">
<el-input v-model="form.title" placeholder="请输入提醒标题" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="请选择类型" style="width: 100%">
<el-option label="系统通知" :value="1" />
<el-option label="待办提醒" :value="2" />
<el-option label="活动提醒" :value="3" />
<el-option label="自定义" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input <el-input
v-model="form.content" v-model="form.content"
type="textarea" type="textarea"
:rows="5" :rows="6"
placeholder="请输入提醒内容" placeholder="请输入日程详细内容"
maxlength="500" maxlength="500"
show-word-limit show-word-limit
/> />
</el-form-item> </el-form-item>
<el-form-item label="提醒时间" prop="remindTime"> <el-form-item label="日程发生时间" prop="schedule_time">
<el-date-picker <el-date-picker
v-model="form.remindTime" v-model="form.schedule_time"
type="datetime" type="datetime"
placeholder="请选择提醒时间" placeholder="请选择发生时间"
format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="接收范围" prop="targetType"> <el-form-item label="提醒渠道" prop="remind_channels">
<el-select v-model="form.targetType" placeholder="请选择接收范围" style="width: 100%"> <el-checkbox-group v-model="form.remind_channels">
<el-option label="全体用户" :value="1" /> <el-checkbox value="SMS">短信 (SMS)</el-checkbox>
<el-option label="指定用户" :value="2" /> <el-checkbox value="EMAIL">邮件 (EMAIL)</el-checkbox>
<el-option label="指定角色" :value="3" /> <el-checkbox value="BARK">Bark 推送</el-checkbox>
<el-option label="仅管理员" :value="4" /> <el-checkbox value="SITE_MSG">站内信 (SITE_MSG)</el-checkbox>
</el-select> </el-checkbox-group>
</el-form-item> </el-form-item>
<!-- 指定用户时展示 --> <el-form-item label="提前提醒分钟" prop="advance_minutes">
<el-form-item <el-input-number
v-if="form.targetType === 2" v-model="form.advance_minutes"
label="指定用户" :min="0"
prop="targetIds" :max="1440"
> style="width: 100%"
<el-input controls-position="right"
v-model="form.targetIds"
placeholder="多个用户 ID用英文逗号分隔1,2,3"
/> />
<span class="form-tip">提前多少分钟开始发送第一次提醒</span>
</el-form-item> </el-form-item>
<!-- 指定角色时展示 --> <!-- 仅在勾选了邮件或Bark时展示重复配置 -->
<el-form-item <template v-if="hasRepeatChannel">
v-if="form.targetType === 3" <el-divider content-position="left">重复发送设置 (仅EMAIL/BARK生效)</el-divider>
label="指定角色"
prop="targetIds"
>
<el-input
v-model="form.targetIds"
placeholder="多个角色 ID用英文逗号分隔1,2,3"
/>
</el-form-item>
<el-form-item label="是否重复"> <el-form-item label="重复间隔(分钟)" prop="repeat_interval_minutes">
<el-switch v-model="form.isRepeat" :active-value="1" :inactive-value="0" /> <el-input-number
<span class="form-tip">开启后按下方周期重复发送</span> v-model="form.repeat_interval_minutes"
</el-form-item> :min="1"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<span class="form-tip">未确认前每隔多少分钟重新发送一次</span>
</el-form-item>
<el-form-item v-if="form.isRepeat === 1" label="重复周期" prop="repeatCycle"> <el-form-item label="最大发送次数" prop="max_send_count">
<el-select v-model="form.repeatCycle" placeholder="请选择" style="width: 100%"> <el-input-number
<el-option label="每天" value="daily" /> v-model="form.max_send_count"
<el-option label="每周" value="weekly" /> :min="1"
<el-option label="每月" value="monthly" /> :max="100"
</el-select> style="width: 100%"
</el-form-item> controls-position="right"
/>
<el-form-item label="备注"> <span class="form-tip">防骚扰兜底发送达到该次数后自动停止</span>
<el-input </el-form-item>
v-model="form.remark" </template>
type="textarea"
:rows="2"
placeholder="选填"
maxlength="200"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button :value="1">启用</el-radio-button>
<el-radio-button :value="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visible = false">取消</el-button> <div class="drawer-footer">
<el-button type="primary" :loading="saving" @click="submit">保存</el-button> <el-button
type="warning"
:loading="testing"
:disabled="!form.remind_channels.length"
@click="handleTest"
>
测试通道
</el-button>
<div style="flex: 1;"></div>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</div>
</template> </template>
</el-drawer> </el-drawer>
</template> </template>
<script setup> <script setup>
import { ref, reactive, nextTick } from 'vue' import { ref, reactive, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getReminderDetail, createReminder, updateReminder } from '@/api/reminder' import { getReminderDetail, createReminder, updateReminder, testReminder } from '@/api/reminder'
const emit = defineEmits(['saved']) const emit = defineEmits(['saved'])
const visible = ref(false) const visible = ref(false)
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const testing = ref(false)
const isAdd = ref(true) const isAdd = ref(true)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ const form = reactive({
id: 0, id: 0,
title: '',
type: 1,
content: '', content: '',
remindTime: '', schedule_time: '',
targetType: 1, remind_channels: [],
targetIds: '', advance_minutes: 0,
isRepeat: 0, repeat_interval_minutes: 10,
repeatCycle: '', max_send_count: 5,
remark: '',
status: 1,
}) })
const rules = { const rules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }], content: [{ required: true, message: '请输入日程内容', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }], schedule_time: [{ required: true, message: '请选择日程发生时间', trigger: 'change' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }], remind_channels: [{ type: 'array', required: true, message: '请选择至少一个提醒渠道', trigger: 'change' }],
remindTime: [{ required: true, message: '请选择提醒时间', trigger: 'change' }], repeat_interval_minutes: [{ required: true, message: '请输入重复提醒间隔', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择接收范围', trigger: 'change' }], max_send_count: [{ required: true, message: '请输入最大发送次数', trigger: 'blur' }],
repeatCycle: [
{
validator: (rule, value, callback) => {
if (form.isRepeat === 1 && !value) {
callback(new Error('请选择重复周期'))
} else {
callback()
}
},
trigger: 'change',
},
],
} }
// /
const hasRepeatChannel = computed(() => {
return form.remind_channels.includes('EMAIL') || form.remind_channels.includes('BARK')
})
function resetForm() { function resetForm() {
form.id = 0 form.id = 0
form.title = ''
form.type = 1
form.content = '' form.content = ''
form.remindTime = '' form.schedule_time = ''
form.targetType = 1 form.remind_channels = []
form.targetIds = '' form.advance_minutes = 0
form.isRepeat = 0 form.repeat_interval_minutes = 10
form.repeatCycle = '' form.max_send_count = 5
form.remark = ''
form.status = 1
} }
async function open(id) { async function open(id) {
@ -198,16 +168,12 @@ async function open(id) {
} }
const d = res.data const d = res.data
form.id = d.id form.id = d.id
form.title = d.title || ''
form.type = d.type ?? 1
form.content = d.content || '' form.content = d.content || ''
form.remindTime = d.remindTime || '' form.schedule_time = d.schedule_time || ''
form.targetType = d.targetType ?? 1 form.remind_channels = d.remind_channels || []
form.targetIds = d.targetIds || '' form.advance_minutes = d.advance_minutes ?? 0
form.isRepeat = d.isRepeat ?? 0 form.repeat_interval_minutes = d.repeat_interval_minutes ?? 10
form.repeatCycle = d.repeatCycle || '' form.max_send_count = d.max_send_count ?? 5
form.remark = d.remark || ''
form.status = d.status ?? 1
} finally { } finally {
loading.value = false loading.value = false
} }
@ -218,6 +184,44 @@ function onClosed() {
resetForm() resetForm()
} }
async function handleTest() {
if (form.remind_channels.length === 0) {
ElMessage.warning('请先选择至少一个提醒渠道')
return
}
testing.value = true
try {
const res = await testReminder({
title: '日程提醒',
content: form.content || '这是一条验证日程提醒配置的测试通知。',
remind_channels: form.remind_channels,
})
if (res?.code === 200 && Array.isArray(res.data)) {
const lines = res.data.map(item => {
const name = { SMS: '短信', EMAIL: '邮件', BARK: 'Bark推送', SITE_MSG: '站内信' }[item.channel] || item.channel
const status = item.success
? '<span style="color: #67C23A; font-weight: bold;">发送成功</span>'
: `<span style="color: #F56C6C; font-weight: bold;">发送失败 (${item.msg})</span>`
return `<p style="margin: 8px 0;"><strong>${name}</strong>: ${status}</p>`
}).join('')
await ElMessageBox.alert(
`<div style="font-size: 14px; line-height: 1.6; padding: 10px 0;">${lines}</div>`,
'渠道测试结果',
{ dangerouslyUseHTMLString: true, confirmButtonText: '确定' }
)
} else {
ElMessage.error(res?.msg || '测试发送失败')
}
} catch (err) {
ElMessage.error('测试出错:' + (err.message || err))
} finally {
testing.value = false
}
}
async function submit() { async function submit() {
if (!formRef.value) return if (!formRef.value) return
try { try {
@ -229,16 +233,12 @@ async function submit() {
saving.value = true saving.value = true
try { try {
const payload = { const payload = {
title: form.title,
type: form.type,
content: form.content, content: form.content,
remindTime: form.remindTime, schedule_time: form.schedule_time,
targetType: form.targetType, remind_channels: form.remind_channels,
targetIds: [2, 3].includes(form.targetType) ? form.targetIds : undefined, advance_minutes: Number(form.advance_minutes || 0),
isRepeat: form.isRepeat, repeat_interval_minutes: hasRepeatChannel.value ? Number(form.repeat_interval_minutes || 0) : 0,
repeatCycle: form.isRepeat === 1 ? form.repeatCycle : undefined, max_send_count: hasRepeatChannel.value ? Number(form.max_send_count || 1) : 1,
remark: form.remark || undefined,
status: form.status,
} }
let res let res
@ -264,9 +264,22 @@ defineExpose({ open })
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.reminder-form {
padding: 10px 20px 40px 0;
}
.form-tip { .form-tip {
margin-left: 10px; display: block;
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.el-divider {
margin: 24px 0 16px;
}
.drawer-footer {
display: flex;
align-items: center;
width: 100%;
} }
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="container-box"> <div class="container-box">
<div class="header-bar"> <div class="header-bar">
<h2>提醒管理</h2> <h2>日程提醒管理</h2>
<div class="header-actions"> <div class="header-actions">
<el-button type="primary" @click="editRef.open()"> <el-button type="primary" @click="editRef.open()">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
@ -21,49 +21,13 @@
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
v-model="searchForm.keyword" v-model="searchForm.keyword"
placeholder="标题/内容" placeholder="内容"
clearable clearable
style="width: 200px" style="width: 200px"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
@clear="handleSearch" @clear="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="类型">
<el-select
v-model="searchForm.type"
placeholder="全部"
clearable
style="width: 130px"
>
<el-option label="系统通知" :value="1" />
<el-option label="待办提醒" :value="2" />
<el-option label="活动提醒" :value="3" />
<el-option label="自定义" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="提醒时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 240px"
@change="handleSearch"
/>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
@ -87,34 +51,68 @@
style="width: 100%" style="width: 100%"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="50" align="center" :selectable="checkSelectable" />
<el-table-column prop="id" label="ID" width="70" align="center" /> <el-table-column prop="id" label="ID" width="75" align="center" />
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip /> <el-table-column prop="content" label="日程提醒内容" min-width="280" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="110" align="center"> <el-table-column prop="schedule_time" label="日程发生时间" width="170" align="center" />
<el-table-column prop="is_finished" label="状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="typeTagType(row.type)" size="small">{{ typeText(row.type) }}</el-tag> <el-tag :type="row.is_finished ? 'info' : 'success'" size="small">
</template> {{ row.is_finished ? '已结束' : '提醒中' }}
</el-table-column>
<el-table-column prop="content" label="内容摘要" min-width="200" show-overflow-tooltip />
<el-table-column prop="remindTime" label="提醒时间" width="170" align="center" />
<el-table-column prop="targetType" label="接收范围" width="110" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">{{ targetTypeText(row.targetType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" align="center" /> <el-table-column prop="remind_channels" label="提醒渠道" min-width="180" align="center">
<el-table-column label="操作" width="150" align="center" fixed="right"> <template #default="{ row }">
<div class="channel-tags">
<el-tag
v-for="ch in (row.remind_channels || [])"
:key="ch"
:type="channelTagType(ch)"
size="small"
class="ch-tag"
>
{{ channelText(ch) }}
</el-tag>
<span v-if="!(row.remind_channels?.length)" style="color: #909399;"></span>
</div>
</template>
</el-table-column>
<el-table-column prop="advance_minutes" label="提前提醒" width="110" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">提前 {{ row.advance_minutes ?? 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button text type="primary" size="small" @click="handleViewDetail(row)">详情</el-button> <el-button text type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
<el-button text type="primary" size="small" @click="editRef.open(row.id)">编辑</el-button> <el-button
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button> v-if="!row.is_finished"
text
type="warning"
size="small"
@click="handleFinish(row)"
>
结束
</el-button>
<el-button
v-if="!row.is_finished"
text
type="primary"
size="small"
@click="editRef.open(row.id)"
>
编辑
</el-button>
<el-button
v-if="!row.is_finished"
text
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -144,7 +142,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue' import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { getReminderList, deleteReminder, batchDeleteReminder } from '@/api/reminder' import { getReminderList, deleteReminder, batchDeleteReminder, finishReminder } from '@/api/reminder'
import ReminderEdit from './components/edit.vue' import ReminderEdit from './components/edit.vue'
import ReminderDetail from './components/detail.vue' import ReminderDetail from './components/detail.vue'
@ -154,14 +152,9 @@ const detailRef = ref(null)
const loading = ref(false) const loading = ref(false)
const list = ref([]) const list = ref([])
const selectedIds = ref([]) const selectedIds = ref([])
const dateRange = ref(null)
const searchForm = reactive({ const searchForm = reactive({
keyword: '', keyword: '',
type: null,
status: null,
startDate: '',
endDate: '',
}) })
const pagination = reactive({ const pagination = reactive({
@ -170,22 +163,30 @@ const pagination = reactive({
total: 0, total: 0,
}) })
// const channelTextMap = {
function typeText(type) { SMS: '短信',
const map = { 1: '系统通知', 2: '待办提醒', 3: '活动提醒', 4: '自定义' } EMAIL: '邮件',
return map[type] ?? '未知' BARK: 'Bark',
SITE_MSG: '站内信',
} }
// const channelColorMap = {
function typeTagType(type) { SMS: 'success',
const map = { 1: 'primary', 2: 'warning', 3: 'success', 4: '' } EMAIL: 'warning',
return map[type] ?? 'info' BARK: 'danger',
SITE_MSG: 'primary',
} }
// function channelText(ch) {
function targetTypeText(targetType) { return channelTextMap[ch] || ch
const map = { 1: '全体用户', 2: '指定用户', 3: '指定角色', 4: '仅管理员' } }
return map[targetType] ?? '—'
function channelTagType(ch) {
return channelColorMap[ch] || 'info'
}
function checkSelectable(row) {
return !row.is_finished
} }
// //
@ -196,10 +197,6 @@ async function fetchList() {
page: pagination.page, page: pagination.page,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined, keyword: searchForm.keyword || undefined,
type: searchForm.type != null ? searchForm.type : undefined,
status: searchForm.status != null ? searchForm.status : undefined,
startDate: searchForm.startDate || undefined,
endDate: searchForm.endDate || undefined,
} }
const res = await getReminderList(params) const res = await getReminderList(params)
if (res?.code === 200 && res.data) { if (res?.code === 200 && res.data) {
@ -216,13 +213,6 @@ async function fetchList() {
// //
function handleSearch() { function handleSearch() {
if (dateRange.value && dateRange.value.length === 2) {
searchForm.startDate = dateRange.value[0]
searchForm.endDate = dateRange.value[1]
} else {
searchForm.startDate = ''
searchForm.endDate = ''
}
pagination.page = 1 pagination.page = 1
fetchList() fetchList()
} }
@ -230,11 +220,6 @@ function handleSearch() {
// //
function resetSearch() { function resetSearch() {
searchForm.keyword = '' searchForm.keyword = ''
searchForm.type = null
searchForm.status = null
searchForm.startDate = ''
searchForm.endDate = ''
dateRange.value = null
pagination.page = 1 pagination.page = 1
fetchList() fetchList()
} }
@ -249,10 +234,34 @@ function handleViewDetail(row) {
detailRef.value?.open(row.id) detailRef.value?.open(row.id)
} }
//
async function handleFinish(row) {
try {
await ElMessageBox.confirm('确定结束该日程提醒吗?结束后将不再发送任何提醒。', '提示', {
type: 'warning',
confirmButtonText: '确定结束',
cancelButtonText: '取消',
})
} catch {
return
}
const res = await finishReminder(row.id)
if (res?.code === 200) {
ElMessage.success('已结束')
fetchList()
} else {
ElMessage.error(res?.msg || '结束失败')
}
}
// //
async function handleDelete(row) { async function handleDelete(row) {
if (row.is_finished) {
ElMessage.warning('已结束的日程无法删除')
return
}
try { try {
await ElMessageBox.confirm(`确定删除提醒「${row.title}」吗?`, '提示', { type: 'warning' }) await ElMessageBox.confirm('确定删除该日程及其所有关联提醒吗?', '提示', { type: 'warning' })
} catch { } catch {
return return
} }
@ -268,7 +277,7 @@ async function handleDelete(row) {
// //
async function handleBatchDelete() { async function handleBatchDelete() {
try { try {
await ElMessageBox.confirm(`确定批量删除选中的 ${selectedIds.value.length} 条提醒吗?`, '提示', { await ElMessageBox.confirm(`确定批量删除选中的 ${selectedIds.value.length} 个日程吗?`, '提示', {
type: 'warning', type: 'warning',
}) })
} catch { } catch {
@ -315,4 +324,15 @@ onMounted(() => {
.search-form { .search-form {
margin-bottom: 12px; margin-bottom: 12px;
} }
.channel-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
}
.ch-tag {
margin: 2px;
}
</style> </style>