From bd30b687663e507c81fcd27750279a19d6009045 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com>
Date: Thu, 18 Jun 2026 00:55:14 +0800
Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=97=A5=E7=A8=8B=E6=8F=90?=
=?UTF-8?q?=E9=86=92=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
go/controllers/api_reminder.go | 109 +++
go/controllers/platform_reminder.go | 631 ++++++++++++++++++
go/main.go | 4 +
go/models/init.go | 4 +
go/models/platform_schedule_reminder.go | 55 ++
go/routers/api/api.go | 3 +
go/routers/platform/platform.go | 7 +
go/services/reminder_scheduler.go | 371 ++++++++++
go/services/system_email_smtp.go | 94 +++
platform/components.d.ts | 1 +
platform/src/App.vue | 5 +-
platform/src/api/reminder.js | 19 +-
.../components/notificationSettings.vue | 2 +-
.../tools/reminder/components/detail.vue | 116 ++--
.../views/tools/reminder/components/edit.vue | 285 ++++----
platform/src/views/tools/reminder/index.vue | 212 +++---
16 files changed, 1621 insertions(+), 297 deletions(-)
create mode 100644 go/controllers/api_reminder.go
create mode 100644 go/controllers/platform_reminder.go
create mode 100644 go/models/platform_schedule_reminder.go
create mode 100644 go/services/reminder_scheduler.go
diff --git a/go/controllers/api_reminder.go b/go/controllers/api_reminder.go
new file mode 100644
index 0000000..3230026
--- /dev/null
+++ b/go/controllers/api_reminder.go
@@ -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(`
+
+
+
+
+ 确认收到提醒
+
+
+
+
+
提示
+
该日程提醒在此之前已确认过了。
+
无需重复点击,感谢您的使用!
+
+
+
+ `))
+ 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(`
+
+
+
+
+ 确认成功
+
+
+
+
+
确认成功
+
您已成功确认收到该日程提醒!
+
系统已停止向您重复推送,感谢您的配合。
+
+
+
+ `))
+}
diff --git a/go/controllers/platform_reminder.go b/go/controllers/platform_reminder.go
new file mode 100644
index 0000000..97ed833
--- /dev/null
+++ b/go/controllers/platform_reminder.go
@@ -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)
+}
diff --git a/go/main.go b/go/main.go
index 525a065..6ada88c 100644
--- a/go/main.go
+++ b/go/main.go
@@ -2,6 +2,7 @@ package main
import (
"server/models"
+ "server/services"
_ "server/routers"
"server/version"
@@ -21,5 +22,8 @@ func main() {
// 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件
beego.SetStaticPath("/uploads", "uploads")
+ // 启动日程提醒定时任务
+ services.StartReminderScheduler(make(chan struct{}))
+
beego.Run()
}
diff --git a/go/models/init.go b/go/models/init.go
index fa05bbb..c02f7ee 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -69,6 +69,10 @@ func Init(_ string) {
new(SystemNormalSetting),
new(PlatformNormalSetting),
new(BackendNormalSetting),
+
+ new(PlatformSchedule),
+ new(PlatformScheduleReminder),
+ new(PlatformScheduleReminderSendLog),
)
// 创建全局 Ormer
diff --git a/go/models/platform_schedule_reminder.go b/go/models/platform_schedule_reminder.go
new file mode 100644
index 0000000..8e96596
--- /dev/null
+++ b/go/models/platform_schedule_reminder.go
@@ -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"
+}
diff --git a/go/routers/api/api.go b/go/routers/api/api.go
index 6e8af17..1281df9 100644
--- a/go/routers/api/api.go
+++ b/go/routers/api/api.go
@@ -30,4 +30,7 @@ func Register() {
// 对外提卡接口(无需登录)
// GET /api/getcard?type=xianyu&module=cursor&data_type=tk
beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard")
+
+ // 日程提醒确认接口(无需登录)
+ beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder")
}
diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go
index 61842bc..80fd3b0 100644
--- a/go/routers/platform/platform.go
+++ b/go/routers/platform/platform.go
@@ -255,4 +255,11 @@ func Register() {
beego.Router("/platform/notebook/create", &controllers.PlatformNotebookController{}, "post:Create")
beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update")
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")
}
diff --git a/go/services/reminder_scheduler.go b/go/services/reminder_scheduler.go
new file mode 100644
index 0000000..fc4df65
--- /dev/null
+++ b/go/services/reminder_scheduler.go
@@ -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(`
+
+
日程提醒:%s
+
%s
+
+ `, title, content)
+
+ if ackToken != "" {
+ ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken)
+ htmlBody += fmt.Sprintf(`
+
+
确认收到后,系统将不再向您发送该日程的重复提醒。
+ `, ackURL)
+ }
+
+ htmlBody += "
"
+
+ 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 获取日程信息(主要拿 Content,Title 统一为 "日程提醒")
+ 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(),
+ })
+ }
+}
diff --git a/go/services/system_email_smtp.go b/go/services/system_email_smtp.go
index 9d4e902..4eb3abc 100644
--- a/go/services/system_email_smtp.go
+++ b/go/services/system_email_smtp.go
@@ -117,6 +117,100 @@ func SendTestEmailSMTP(cfg SMTPConfig, to string) error {
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 {
name = strings.TrimSpace(name)
if name == "" {
diff --git a/platform/components.d.ts b/platform/components.d.ts
index 7ffac2a..2e1dc36 100644
--- a/platform/components.d.ts
+++ b/platform/components.d.ts
@@ -25,6 +25,7 @@ declare module 'vue' {
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
+ ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
diff --git a/platform/src/App.vue b/platform/src/App.vue
index bf9547c..8e6b25f 100644
--- a/platform/src/App.vue
+++ b/platform/src/App.vue
@@ -1,8 +1,11 @@
-
+
+
+
diff --git a/platform/src/views/tools/reminder/components/edit.vue b/platform/src/views/tools/reminder/components/edit.vue
index 4569f29..d6f211b 100644
--- a/platform/src/views/tools/reminder/components/edit.vue
+++ b/platform/src/views/tools/reminder/components/edit.vue
@@ -1,8 +1,8 @@
@@ -10,174 +10,144 @@
ref="formRef"
:model="form"
:rules="rules"
- label-width="100px"
+ label-width="120px"
v-loading="loading"
+ label-position="right"
+ class="reminder-form"
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
-
-
-
-
-
-
+
+
+ 短信 (SMS)
+ 邮件 (EMAIL)
+ Bark 推送
+ 站内信 (SITE_MSG)
+
-
-
-
+
+ 提前多少分钟开始发送第一次提醒
-
-
-
-
+
+
+ 重复发送设置 (仅EMAIL/BARK生效)
-
-
- 开启后按下方周期重复发送
-
+
+
+ 未确认前,每隔多少分钟重新发送一次
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 启用
- 禁用
-
-
+
+
+ 防骚扰兜底,发送达到该次数后自动停止
+
+
- 取消
- 保存
+
diff --git a/platform/src/views/tools/reminder/index.vue b/platform/src/views/tools/reminder/index.vue
index 076f9fd..9952027 100644
--- a/platform/src/views/tools/reminder/index.vue
+++ b/platform/src/views/tools/reminder/index.vue
@@ -1,7 +1,7 @@