From f00ba9f9cc0ef18f2953a8a2a91a503b1b37a5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Mon, 20 Jul 2026 11:41:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=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 --- .mimocode/.cron-lock | 2 +- backend/vite.config.js | 8 + go/controllers/api_reminder.go | 277 ++++-- go/controllers/app_reminder.go | 90 +- go/controllers/backend_reminder.go | 63 +- go/controllers/platform_reminder.go | 1267 ++++++++++++------------ go/services/reminder_scheduler.go | 759 +++++++------- platform/package-lock.json | 470 ++++----- platform/package.json | 8 + platform/src/views/dashboard/index.vue | 49 +- platform/vite.config.js | 6 +- uniapp/manifest.json | 17 +- uniapp/pages/dashboard/dashboard.vue | 86 +- uniapp/pages/features/features.vue | 6 +- uniapp/pages/profile/profile.vue | 98 +- uniapp/pages/tools/notepad/edit.vue | 105 +- uniapp/pages/tools/schedule/edit.vue | 98 +- uniapp/src/styles/theme.scss | 8 +- uniapp/vite.config.js | 43 +- 19 files changed, 1735 insertions(+), 1725 deletions(-) diff --git a/.mimocode/.cron-lock b/.mimocode/.cron-lock index 4873726..bf7d9c6 100644 --- a/.mimocode/.cron-lock +++ b/.mimocode/.cron-lock @@ -1 +1 @@ -{"pid":54204,"startedAt":1784192140605} \ No newline at end of file +{"pid":4264,"startedAt":1784510683025} \ No newline at end of file diff --git a/backend/vite.config.js b/backend/vite.config.js index 64a4864..ea0e2a5 100644 --- a/backend/vite.config.js +++ b/backend/vite.config.js @@ -22,6 +22,14 @@ export default defineConfig({ }, }, server: { + host: '127.0.0.1', port: 4001, + proxy: { + '/api': { + target: 'http://127.0.0.1:9000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + } + } }, }); diff --git a/go/controllers/api_reminder.go b/go/controllers/api_reminder.go index 7ac822b..af468ef 100644 --- a/go/controllers/api_reminder.go +++ b/go/controllers/api_reminder.go @@ -1,109 +1,168 @@ -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(` - - - - - 确认成功 - - - -
-

确认成功

-

您已成功确认收到该日程提醒!

-

系统已停止向您重复推送,感谢您的配合。

-
- - - `)) -} +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 客户端访问此接口进行提醒确认。 +// backend 与 platform 使用各自的提醒表,这里按 token 自动识别来源。 +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 + } + + type reminderState struct { + AckStatus int8 + ScheduleID uint64 + RemindStatus int8 + } + + var state reminderState + table := "" + + var backendReminder models.BackendScheduleReminder + if err := models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("ack_token", token). + Filter("is_deleted", 0). + One(&backendReminder); err == nil { + state = reminderState{ + AckStatus: backendReminder.AckStatus, + ScheduleID: backendReminder.ScheduleID, + RemindStatus: backendReminder.RemindStatus, + } + table = "backend" + } else { + var platformReminder models.PlatformScheduleReminder + if err := models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("ack_token", token). + Filter("is_deleted", 0). + One(&platformReminder); err != nil { + c.Ctx.Output.SetStatus(404) + _ = c.Ctx.Output.Body([]byte("Error: reminder task not found or token has expired")) + return + } + state = reminderState{ + AckStatus: platformReminder.AckStatus, + ScheduleID: platformReminder.ScheduleID, + RemindStatus: platformReminder.RemindStatus, + } + table = "platform" + } + + if state.AckStatus == 1 { + writeReminderAckAlreadyConfirmed(c) + return + } + + now := time.Now() + var err error + if table == "backend" { + _, err = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("ack_token", token). + Filter("is_deleted", 0). + Update(map[string]interface{}{ + "AckStatus": int8(1), + "AckTime": now, + "RemindStatus": int8(2), + "UpdateTime": now, + }) + } else { + _, err = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("ack_token", token). + Filter("is_deleted", 0). + Update(map[string]interface{}{ + "AckStatus": int8(1), + "AckTime": now, + "RemindStatus": int8(2), + "UpdateTime": now, + }) + } + if err != nil { + c.Ctx.Output.SetStatus(500) + _ = c.Ctx.Output.Body([]byte("Database error, please try again later")) + return + } + + // 只关闭命中来源表中、同一日程的其他待提醒记录,避免两套表互相影响。 + if table == "backend" { + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", state.ScheduleID). + Filter("remind_status__in", 0, 1). + Update(map[string]interface{}{ + "RemindStatus": int8(2), + "UpdateTime": now, + }) + } else { + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", state.ScheduleID). + Filter("remind_status__in", 0, 1). + Update(map[string]interface{}{ + "RemindStatus": int8(2), + "UpdateTime": now, + }) + } + + writeReminderAckSuccess(c) +} + +func writeReminderAckAlreadyConfirmed(c *ApiReminderController) { + c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8") + _ = c.Ctx.Output.Body([]byte(` + + + + + 确认收到提醒 + + + +
+

提示

+

该日程提醒在此之前已确认过了。

+

无需重复点击,感谢您的使用!

+
+ + + `)) +} + +func writeReminderAckSuccess(c *ApiReminderController) { + c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8") + _ = c.Ctx.Output.Body([]byte(` + + + + + 确认成功 + + + +
+

确认成功

+

您已成功确认收到该日程提醒!

+

系统已停止向您重复推送,感谢您的配合。

+
+ + + `)) +} diff --git a/go/controllers/app_reminder.go b/go/controllers/app_reminder.go index 56579a0..f17edbe 100644 --- a/go/controllers/app_reminder.go +++ b/go/controllers/app_reminder.go @@ -13,6 +13,7 @@ import ( "server/models" "server/pkg/jwtutil" + "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) @@ -23,18 +24,18 @@ type AppReminderController struct { func (c *AppReminderController) appClaims() (*jwtutil.Claims, error) { auth := c.Ctx.Request.Header.Get("Authorization") if auth == "" { - return nil, fmt.Errorf("未登录") + return nil, orm.ErrNoRows } parts := strings.SplitN(auth, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { - return nil, fmt.Errorf("认证信息格式错误") + return nil, orm.ErrNoRows } claims, err := jwtutil.ParseToken(parts[1]) if err != nil { - return nil, fmt.Errorf("无效的token") + return nil, err } if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" { - return nil, fmt.Errorf("无权访问") + return nil, orm.ErrNoRows } return claims, nil } @@ -71,7 +72,7 @@ type appSchedulePayload struct { func (c *AppReminderController) GetList() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -87,8 +88,9 @@ func (c *AppReminderController) GetList() { pageSize = 20 } - qs := models.Orm.QueryTable(new(models.PlatformSchedule)). - Filter("user_id", claims.UserID) + qs := models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("user_id", claims.UserID). + Filter("tid", claims.TenantId) if keyword != "" { qs = qs.Filter("content__contains", keyword) @@ -96,17 +98,20 @@ func (c *AppReminderController) GetList() { total, _ := qs.Count() - var schedules []models.PlatformSchedule + var schedules []models.BackendSchedule _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules) - if err != nil { + if err != nil && err != orm.ErrNoRows { c.jsonErr(500, 500, "查询失败: "+err.Error()) return } + if schedules == nil { + schedules = []models.BackendSchedule{} + } list := make([]map[string]interface{}, 0, len(schedules)) for _, s := range schedules { - var reminders []models.PlatformScheduleReminder - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", s.ID). Filter("is_deleted", 0). All(&reminders) @@ -166,7 +171,7 @@ func (c *AppReminderController) GetList() { func (c *AppReminderController) GetDetail() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -177,24 +182,25 @@ func (c *AppReminderController) GetDetail() { return } - var schedule models.PlatformSchedule - err = models.Orm.QueryTable(new(models.PlatformSchedule)). + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). Filter("user_id", claims.UserID). + Filter("tid", claims.TenantId). One(&schedule) if err != nil { c.jsonErr(404, 404, "日程未找到") return } - var reminders []models.PlatformScheduleReminder - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", schedule.ID). Filter("is_deleted", 0). All(&reminders) channels := make([]string, 0, len(reminders)) - var first models.PlatformScheduleReminder + var first models.BackendScheduleReminder for _, r := range reminders { channels = append(channels, r.RemindChannel) first = r @@ -236,7 +242,7 @@ func (c *AppReminderController) GetDetail() { func (c *AppReminderController) Create() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -275,7 +281,8 @@ func (c *AppReminderController) Create() { title = "日程提醒" } - schedule := models.PlatformSchedule{ + schedule := models.BackendSchedule{ + Tid: claims.TenantId, Title: title, Content: content, ScheduleTime: schedTime, @@ -298,7 +305,8 @@ func (c *AppReminderController) Create() { firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) - reminder := models.PlatformScheduleReminder{ + reminder := models.BackendScheduleReminder{ + Tid: claims.TenantId, ScheduleID: uint64(schedID), RemindChannel: ch, AdvanceMinutes: p.AdvanceMinutes, @@ -336,7 +344,7 @@ func (c *AppReminderController) Create() { func (c *AppReminderController) Update() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -364,18 +372,19 @@ func (c *AppReminderController) Update() { return } - var schedule models.PlatformSchedule - err = models.Orm.QueryTable(new(models.PlatformSchedule)). + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). Filter("user_id", claims.UserID). + Filter("tid", claims.TenantId). One(&schedule) if err != nil { c.jsonErr(404, 404, "日程未找到") return } - var reminders []models.PlatformScheduleReminder - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", id). Filter("is_deleted", 0). All(&reminders) @@ -414,7 +423,7 @@ func (c *AppReminderController) Update() { } // 软删除旧提醒,重建 - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", id). Update(map[string]interface{}{ "IsDeleted": 1, @@ -432,7 +441,8 @@ func (c *AppReminderController) Update() { firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) - reminder := models.PlatformScheduleReminder{ + reminder := models.BackendScheduleReminder{ + Tid: claims.TenantId, ScheduleID: id, RemindChannel: ch, AdvanceMinutes: p.AdvanceMinutes, @@ -470,7 +480,7 @@ func (c *AppReminderController) Update() { func (c *AppReminderController) Delete() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -481,18 +491,19 @@ func (c *AppReminderController) Delete() { return } - var schedule models.PlatformSchedule - err = models.Orm.QueryTable(new(models.PlatformSchedule)). + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). Filter("user_id", claims.UserID). + Filter("tid", claims.TenantId). One(&schedule) if err != nil { c.jsonErr(404, 404, "日程未找到") return } - var reminders []models.PlatformScheduleReminder - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", id). Filter("is_deleted", 0). All(&reminders) @@ -512,9 +523,9 @@ func (c *AppReminderController) Delete() { return } - _, err = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).Delete() + _, err = models.Orm.QueryTable(new(models.BackendSchedule)).Filter("id", id).Delete() if err == nil { - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", id). Update(map[string]interface{}{ "IsDeleted": 1, @@ -530,7 +541,7 @@ func (c *AppReminderController) Delete() { func (c *AppReminderController) ToggleComplete() { claims, err := c.appClaims() if err != nil { - c.jsonErr(401, 401, err.Error()) + c.jsonErr(401, 401, "未登录或无权限") return } @@ -541,18 +552,19 @@ func (c *AppReminderController) ToggleComplete() { return } - var schedule models.PlatformSchedule - err = models.Orm.QueryTable(new(models.PlatformSchedule)). + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). Filter("user_id", claims.UserID). + Filter("tid", claims.TenantId). One(&schedule) if err != nil { c.jsonErr(404, 404, "日程未找到") return } - var reminders []models.PlatformScheduleReminder - _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", id). Filter("is_deleted", 0). All(&reminders) diff --git a/go/controllers/backend_reminder.go b/go/controllers/backend_reminder.go index 77e43d1..20064e7 100644 --- a/go/controllers/backend_reminder.go +++ b/go/controllers/backend_reminder.go @@ -210,13 +210,13 @@ func (c *BackendReminderController) GetReminderDetail() { } 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, + "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, + "is_finished": isFinished, } if first.ID > 0 { data["advance_minutes"] = first.AdvanceMinutes @@ -295,16 +295,16 @@ func (c *BackendReminderController) CreateReminder() { firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) reminder := models.BackendScheduleReminder{ - Tid: claims.TenantId, - ScheduleID: uint64(schedID), - RemindChannel: ch, - AdvanceMinutes: p.AdvanceMinutes, - NextRemindTime: firstSendTime, - ReceiverUserID: uint64(claims.UserID), - ReceiverTarget: target, - RemindStatus: 0, - CreateTime: time.Now(), - UpdateTime: time.Now(), + Tid: claims.TenantId, + 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" { @@ -433,16 +433,16 @@ func (c *BackendReminderController) UpdateReminder() { firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) reminder := models.BackendScheduleReminder{ - Tid: claims.TenantId, - ScheduleID: id, - RemindChannel: ch, - AdvanceMinutes: p.AdvanceMinutes, - NextRemindTime: firstSendTime, - ReceiverUserID: schedule.UserID, - ReceiverTarget: target, - RemindStatus: 0, - CreateTime: time.Now(), - UpdateTime: time.Now(), + Tid: claims.TenantId, + 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" { @@ -703,13 +703,20 @@ func (c *BackendReminderController) TestReminder() { } dummyToken := "test-token-for-verification" - reminder := &models.PlatformScheduleReminder{ + reminder := &models.BackendScheduleReminder{ + Tid: claims.TenantId, RemindChannel: ch, ReceiverUserID: uint64(claims.UserID), AckToken: &dummyToken, } - success, sendErr := sender.Send(context.Background(), reminder, "[测试]"+p.Title, p.Content) + success, sendErr := sender.Send(context.Background(), services.ReminderData{ + RemindChannel: reminder.RemindChannel, + ReceiverUserID: reminder.ReceiverUserID, + ReceiverTarget: reminder.ReceiverTarget, + AckToken: reminder.AckToken, + Tid: reminder.Tid, + }, "[测试]"+p.Title, p.Content) msg := "发送成功" if !success { msg = "发送失败" diff --git a/go/controllers/platform_reminder.go b/go/controllers/platform_reminder.go index df7361f..e8164c8 100644 --- a/go/controllers/platform_reminder.go +++ b/go/controllers/platform_reminder.go @@ -1,631 +1,636 @@ -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) -} +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(), services.ReminderData{ + RemindChannel: reminder.RemindChannel, + ReceiverUserID: reminder.ReceiverUserID, + ReceiverTarget: reminder.ReceiverTarget, + AckToken: reminder.AckToken, + }, "[测试]"+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/services/reminder_scheduler.go b/go/services/reminder_scheduler.go index 39ea447..a724264 100644 --- a/go/services/reminder_scheduler.go +++ b/go/services/reminder_scheduler.go @@ -1,371 +1,388 @@ -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", "https://api.yunzer.cn") - 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", "https://api.yunzer.cn") - 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(), - }) - } -} +package services + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "server/models" +) + +type ReminderData struct { + RemindChannel string + ReceiverUserID uint64 + ReceiverTarget *string + AckToken *string + Tid int +} + +// ReminderSender 提醒发送接口 +type ReminderSender interface { + Send(ctx context.Context, reminder ReminderData, title, content string) (success bool, err error) +} + +// SMSSender 短信发送实现 +type SMSSender struct{} + +func (s *SMSSender) Send(ctx context.Context, reminder ReminderData, 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 ReminderData, 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", "https://api.yunzer.cn") + 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 ReminderData, 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", "https://api.yunzer.cn") + 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 ReminderData, title, content string) (bool, error) { + now := time.Now() + msg := &models.SystemReminderList{ + Title: title, + Content: content, + SenderID: 0, + SenderType: "system", + ReceiverID: reminder.ReceiverUserID, + ReceiverType: "tenant", // backend/租户用户 + IsRead: 0, + CreateTime: &now, + TargetType: "tenant", + TargetTenantID: uint64(reminder.Tid), + } + _, 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_backend_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.BackendScheduleReminder + _, err = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + 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.BackendSchedule + err := models.Orm.QueryTable(new(models.BackendSchedule)). + 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.BackendScheduleReminder)). + 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, ReminderData{ + RemindChannel: reminder.RemindChannel, + ReceiverUserID: reminder.ReceiverUserID, + ReceiverTarget: reminder.ReceiverTarget, + AckToken: reminder.AckToken, + Tid: reminder.Tid, + }, 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.BackendScheduleReminderSendLog{ + Tid: reminder.Tid, + 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.BackendScheduleReminder)). + Filter("id", reminder.ID). + Update(map[string]interface{}{ + "SendCount": newSendCount, + "NextRemindTime": reminder.NextRemindTime, + "RemindStatus": newStatus, + "ScanLock": "", // 释放扫描锁 + "UpdateTime": time.Now(), + }) + } +} diff --git a/platform/package-lock.json b/platform/package-lock.json index 239b5b1..5990ee2 100644 --- a/platform/package-lock.json +++ b/platform/package-lock.json @@ -9,24 +9,14 @@ "version": "0.0.0", "dependencies": { "@element-plus/icons-vue": "^2.3.2", - "@tiptap/extension-color": "^3.27.4", - "@tiptap/extension-highlight": "^3.27.4", - "@tiptap/extension-image": "^3.27.4", - "@tiptap/extension-link": "^3.27.4", - "@tiptap/extension-placeholder": "^3.27.4", - "@tiptap/extension-subscript": "^3.27.4", - "@tiptap/extension-superscript": "^3.27.4", - "@tiptap/extension-table": "^3.27.4", - "@tiptap/extension-table-cell": "^3.27.4", - "@tiptap/extension-table-header": "^3.27.4", - "@tiptap/extension-table-row": "^3.27.4", - "@tiptap/extension-text-align": "^3.27.4", - "@tiptap/extension-text-style": "^3.27.4", - "@tiptap/extension-typography": "^3.27.4", - "@tiptap/extension-underline": "^3.27.4", - "@tiptap/pm": "^3.27.4", - "@tiptap/starter-kit": "^3.27.4", - "@tiptap/vue-3": "^3.27.4", + "@tiptap/extension-color": "^3.28.0", + "@tiptap/extension-image": "^3.28.0", + "@tiptap/extension-link": "^3.28.0", + "@tiptap/extension-text-align": "^3.28.0", + "@tiptap/extension-text-style": "^3.28.0", + "@tiptap/extension-underline": "^3.28.0", + "@tiptap/starter-kit": "^3.28.0", + "@tiptap/vue-3": "^3.28.0", "@umoteam/editor": "^10.2.1", "axios": "^1.13.1", "chart": "^0.1.2", @@ -1174,49 +1164,49 @@ ] }, "node_modules/@tiptap/core": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.27.4.tgz", - "integrity": "sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.28.0.tgz", + "integrity": "sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "3.27.4" + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.27.4.tgz", - "integrity": "sha512-d1tOHgP3R5cOE+Ot8qL/dkLXRByajgn+j6cCXHqDtmJO2wsK9knmbKQ0SEjbKrU6OgHrTnY/EotNxBEBW9HGoA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.28.0.tgz", + "integrity": "sha512-y8Xi6Z3AQLXedz0J8Z3kDsu7SKBmL50gKVXx3RK1Oo1cuCGhNChm3syChkAz3PLAbrPUM5rvJDSZdF2jUrDnng==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.27.4.tgz", - "integrity": "sha512-wTtJUUAxCAZ01ICH2DNlOBzzHKRQ1ZST8aRYtIhBPzqEUhnJaKGcjnDB4X49fqPi48iXaPxzhsInDl+rVUujWg==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.28.0.tgz", + "integrity": "sha512-JhZQmr0AU741bOwjVMfuwJdK4g0TQwwPbeca9aqKHv5zvZw4i4G9G6fESVyMFc3Yag1ffpnq5EtNldHKTnMhlw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.4.tgz", - "integrity": "sha512-Poy7xwcD3POG5ew/TW7mYXv7m++vCchvHxPUqIfnTxBxvvvqDZkPYFWZS1lvPrSBtm1DcfUTQAgVutM5NDZ99Q==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.28.0.tgz", + "integrity": "sha512-7AUNoHj2K4XLKCgW4uspeD/ENejPu2BeHvLTsiOoIO+XXDNSi2j0bbaIS8f2s/qnFirscwp/sbznp1qph/76qg==", "license": "MIT", "optional": true, "dependencies": { @@ -1227,48 +1217,48 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.4.tgz", - "integrity": "sha512-rvja0N1RnwGJAVwDdbUfDIJ4NoT+KjPFaZudKiPuEMfMHfbqe4xcbbC2hsfs61JNcl2xmx+ohV6lzD9YxxJl1w==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.28.0.tgz", + "integrity": "sha512-dSVuNiH7PFMmWGkNER9vfUb5bXUHDARvHbJ3l+APEb+ZiusVN1KFdM3nd/RsW/Rt5Gd3XwlIEU/c2Uf7cxYDcw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.27.4" + "@tiptap/extension-list": "3.28.0" } }, "node_modules/@tiptap/extension-code": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.27.4.tgz", - "integrity": "sha512-aPc7opCR1ylK4m4c2lsjLsGpEBD1fLQQKWd5PbZiJvrTF8gkdGZlYLt9A6VukpxeJyHhb22Jaj4fxgKmGMeTtw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.28.0.tgz", + "integrity": "sha512-AUw2Acof3CQE6Q6Y22saK4lyg0nkeJ4XXniU65TdAi/9TE66TGiO4NQJJNNPgu8+VMEwl5j8VsIFK8sfTcNYtg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.27.4.tgz", - "integrity": "sha512-a5caWfWN6Z6usy48vzJDDOhWoA6+rFFCHGpQM7jXn/7rRzYPcvBzTZUGptjEbltj4YqtrQ2tVwTJcCtbb+mknA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.28.0.tgz", + "integrity": "sha512-bHITDzT0umTLh+SiEVQzIXkCMt/TzbPM1+HQy9ZxgQDHAJj/vdmfNAnP3RCOrz4lETXyhNQ2b6kxeu3lWckxyA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-code-block-lowlight": { @@ -1305,16 +1295,16 @@ } }, "node_modules/@tiptap/extension-color": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-color/-/extension-color-3.27.4.tgz", - "integrity": "sha512-uGbgErKGKO4OTBGqnXOA1CGXa3IqoBaiPBGcHu/px01fS8EVFu0A7q5HS6vxFiMK6qW5x71sEpCA+FUINMAb3g==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-color/-/extension-color-3.28.0.tgz", + "integrity": "sha512-YbUMUdrgU04GDYoii1wuFVkiTSQoaEQh04kwgQayEQ46aUBEnnaJitWdp39aGMWYTIXD/c9K9cr5Vg8GSKcVxA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-text-style": "3.27.4" + "@tiptap/extension-text-style": "3.28.0" } }, "node_modules/@tiptap/extension-details": { @@ -1333,16 +1323,16 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.27.4.tgz", - "integrity": "sha512-7nAqgfkgb9HADBeCTnOHuTiyZuxfxvMPT3nH4OZeY+cmtkI1On3QffqlmtcUPvNbkhT3o9ehA1hVfCnQ1Ye4LQ==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.28.0.tgz", + "integrity": "sha512-5SAKtlB1Tr/eZFhISL86HqmUup6rItvPtuPyRjmZo/VgbMwXEwiyAh1sMgFp5VNaeSMM14vJSyQpjhSaOmaBqg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-drag-handle": { @@ -1382,22 +1372,22 @@ } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.4.tgz", - "integrity": "sha512-RiZasQJuUTUO3aME16Bn8eJH7cYnvhT5JCFDFq0ya/1iFI9wUQA2NJC5tb5TrZ74+sQwkYU9VzexnchM481Y9w==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.28.0.tgz", + "integrity": "sha512-JA+dXQjjfJBpD0nVsZeddGVXRsmanESM9+8CtM35hI9wJnYMXay/B+5GUhswO20zhT3zhB2ZOTGe9knu6A8nIQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "3.27.4" + "@tiptap/extensions": "3.28.0" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.4.tgz", - "integrity": "sha512-tnZywwoNDuEcUZmYYIztXl3PpIKUq+gKeaYPuZhpYEVTThU44tzK3ZuFOmd+qf2aAa1MQwxKWqUuLpNK77bwNw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.28.0.tgz", + "integrity": "sha512-59SECvJq3pQfeJBuydEdhQqrpl0oDlk8N1Ovs1Si3/fJa6rEQAJB2zIvcpBHky9Z+5JodI2eueDnv8eimiyGbg==", "license": "MIT", "optional": true, "funding": { @@ -1406,87 +1396,74 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.4.tgz", - "integrity": "sha512-svLwSKcFhzpcJeXvxxKkRFuQpykmXrQefVhEsaXq0L95yJIIAGKMRmQC3mxKdzL2j0P9cY7V41bNVSyOAyvclw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.28.0.tgz", + "integrity": "sha512-Wo73kM4q8z4Va9i4+0n1cO0UEMVUVBHPqM6cAqEYXjB4T48LQkKjRsiQydCezm185rQAsmm1dyi72gtvVQfdMg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "3.27.4" + "@tiptap/extensions": "3.28.0" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.27.4.tgz", - "integrity": "sha512-W+Z9pmDgqjbdu3NeZOQrzA15iM4w60Yd8l2CYzxcdApPVIfYzb2S3a7+u1RqW9wnTYb6xyZjASmFNfxXS4P4cg==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.28.0.tgz", + "integrity": "sha512-JHIFjX1luJgQ4VFEkIeXZpbc54Qiw+69P8FmlazYTh7AIJ6iLCpMFgQFELnivEAZ+/vS0lMPQubg0rCeM3UrHw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.27.4.tgz", - "integrity": "sha512-RgvpxzuYk6QEK+az+eiXpWvGlUso42zNcGnjyUrvskoZjS47MbhSg8ylRYQSRtXE0ETlXhAx4J7iGlGr72kyIw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.28.0.tgz", + "integrity": "sha512-rKjGG/Ik2lNaXBNVmONEDm5DBfGDLM1NWgSf5RRfBISrH8Lm1xqFruOB9tIaB0YUoSV3SBOiwTF9svmzvKSoRA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" - } - }, - "node_modules/@tiptap/extension-highlight": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-highlight/-/extension-highlight-3.27.4.tgz", - "integrity": "sha512-STRX1qJLhTZslBF8fEE5qpTGrFd/g7Ufidjxt84p0uT8FrtcbfPUwyweeYwhZp7Iw8n+qODGYnheJTOpfaW2JA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.4.tgz", - "integrity": "sha512-2eQU/55nE5mhMJHALtLMuBL3dcVJUDVVT7n+uZYMaYE63BtCvC4VS08YLFSR7JZSVJIlgVAmdt5nAw0B+rEPNA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.28.0.tgz", + "integrity": "sha512-neBCMWprkppQUoLWyeJZDHqBw+xKX2oNhLD9MbFlhKIr092zgfP4mpCvQnSkn1OHl156KzZMp070+NyLBjgQ7A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-image": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.27.4.tgz", - "integrity": "sha512-yQ8CazyOL4z1/NbV1NLGv6DvchVhOXHH3uQ7md5VX/IGZruFpnm8IpF9MDpdAUxUFmTsXJDYyO2lWGO1PYWG8A==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.28.0.tgz", + "integrity": "sha512-aKGCdEjyujUcQ6tEtq4EPbKxEC16QsKapv3DeZs1c/UHFiEUalbbPHBnxjGnupiLz75IR+07GYqBoZ81kSCxiA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-invisible-characters": { @@ -1505,22 +1482,22 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.27.4.tgz", - "integrity": "sha512-PeZT4XbyxAp7Lqo/hfA1k5LI27g1RlgS+YgXp2CeHXIrUfSpO5HlZXh02Bvb0pOdl3RFw2tEKtlHzjt8Y1+Nwg==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.28.0.tgz", + "integrity": "sha512-Yur/ELz6dNVKQC7m8wGjtoLFxamGjJmA0rUEEOacTH6J39aFmcSxYkEGNDkYHbZFyHFYqbej6eI3VE0h08O0mg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-link": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.27.4.tgz", - "integrity": "sha512-6K/FkNwMLWWQbNWKlycrUPTN7YcyVFdFwZncoBXe5WyarRjLTGw7ywafnCI9PDIWSq7ttzVL4NgjN2IN8kBXww==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.28.0.tgz", + "integrity": "sha512-hy/PqSeTyl317yseFcHGE+3XVfQKQYaL4uZuj27xlrcO7MZ15drt1h9sUZy1FTBF3mr8676pQu7aoEm/Ww4ZRA==", "license": "MIT", "dependencies": { "linkifyjs": "^4.3.3" @@ -1530,48 +1507,48 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-list": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.27.4.tgz", - "integrity": "sha512-A0BgmRO1RE0yLCx9w7GQITtKfS9wLE5cdngSYDiSpwulcXJhJjKm5mZ4OUZmks2VN4HO5jMl2BWCGt2NSDhA+w==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.28.0.tgz", + "integrity": "sha512-zQ66i5DuhVOndmZ0d7H475Y5Yb+BMx+zfCjFkCzEc6WVef5MumtxBVTkGLQ0ibxUaD71s4QQZbjHWagpaTg0eQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.27.4.tgz", - "integrity": "sha512-z5TVuPw2mkK0B/x+gFg3uUV7tBdaElDFg0zVgnXZCqlSVTLfIyInOOnG5LTWoAd9BdzBjGrzE3PohDcLVDDGBQ==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.28.0.tgz", + "integrity": "sha512-GbXrnMac6knSetZY33NHwOrgXFPqH28uCklQqmOZQlsrdGqezDKk31qoEMr4GsKW9n/lViAeuc07oIzwLwCMng==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.27.4" + "@tiptap/extension-list": "3.28.0" } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.4.tgz", - "integrity": "sha512-on7JNDi7Eqz7UdZeZdiO83bQHo0flVDHzjmtR+v/nrCGW9H15D3CHs5+4ozLDiCvTK8tbkBuut/l9AWNxcCE/Q==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.28.0.tgz", + "integrity": "sha512-u9Wks8c/eQAv0RkULNggOyTKDD69WVbcV9H5cbasPDUbq5XbEFVa9ajxhEGfMmQ5et3EX0QL8qBi50K0GqbIjA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.27.4" + "@tiptap/extension-list": "3.28.0" } }, "node_modules/@tiptap/extension-mathematics": { @@ -1619,123 +1596,42 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.4.tgz", - "integrity": "sha512-bHwLiof0FqJfWzB0act7oEKMTZatEKQ4IYCvmyF5EktjMs4kxEatkPp4Yx/1LSYSjLy1MMT7oLELyaz2FFYyXA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.28.0.tgz", + "integrity": "sha512-OZNYrKKL9D01ySOujlNbKlLS9/3wGgKNYeoHZUg0e+Ta8WPVvL3W1zH6TgiU5sF2ZSGUBoq3w7mdaO/iROlUkw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "3.27.4" + "@tiptap/extension-list": "3.28.0" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.27.4.tgz", - "integrity": "sha512-8Dnr1J5s/s4XYYuEF3b784NnCxLjXOlQpmGyXRxTAzW7JaOP08tIUJWVNvSMekfXc2vXa33HUbqjxyyWZEQ6LQ==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.28.0.tgz", + "integrity": "sha512-8UMlQIjzqf6r2hSJ/VeJ00RqaOrOB1J5rTk02Vcw2pYeHkOqp+B0ff0HFu4abT9x1q4oXrBAgMsxRtgh/kR14Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" - } - }, - "node_modules/@tiptap/extension-placeholder": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-placeholder/-/extension-placeholder-3.27.4.tgz", - "integrity": "sha512-7hBoFLeddCv1WzkqB0x3coZ1Hp9WZ9wLoRXIUtUhRKMpzFq2IlTtW1iw88g9pTJnL98bCCElN4DZ4mYtaQvmgA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extensions": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.27.4.tgz", - "integrity": "sha512-8OXwcPKuV3ToBBgyvDxH1jQdObK5FIKCGiyIim6qNWiOpi9BhM3XYD+aO1khjv8qIjtoI/DYbizF4ewj09fX2g==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.28.0.tgz", + "integrity": "sha512-VVj2ZZU9QYqiHLcjqMqRYvuHSsokj/AgUl+6TzLrKjlWwyZ18D4H2vkyI0g70iVTKnUpZ3sEy8WA/rqjgBjqBg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" - } - }, - "node_modules/@tiptap/extension-subscript": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-subscript/-/extension-subscript-3.27.4.tgz", - "integrity": "sha512-gs0AexP7GsE9vgdkwhqYgrKu7kD96PjmgCorsGLdA60m71eTavm6j8NN1m1nvcDPO1sTpP8ACs2mhipj+WS5Iw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" - } - }, - "node_modules/@tiptap/extension-superscript": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-superscript/-/extension-superscript-3.27.4.tgz", - "integrity": "sha512-krlpnLeNBF6hVbQDrkuoXiy52v5YctoGPkLnxmftYNtloVrikRpgpetTRsFh0P6hLwv6g/g8SzoM23iMBB3S4A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" - } - }, - "node_modules/@tiptap/extension-table": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-table/-/extension-table-3.27.4.tgz", - "integrity": "sha512-ejQjqt8GjUn4YswG/SsiLr/W3LZApZGUEDW0N7NoOduE0dBZ/pVJHPuqWu33kK+phJjSNCIN+bSAkoWE01rZSw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" - } - }, - "node_modules/@tiptap/extension-table-cell": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-table-cell/-/extension-table-cell-3.27.4.tgz", - "integrity": "sha512-1B7J4ZiaXaGxT2IB3hrwtz0433bFVSWsDB+B125vt2DZQr9bgdX40GLesSPlymqOdMxKJwksCPMan2ON2LkVAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-table": "3.27.4" - } - }, - "node_modules/@tiptap/extension-table-header": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-table-header/-/extension-table-header-3.27.4.tgz", - "integrity": "sha512-V/O690Z6VcHMAWDfgVFiOCwx3eE2/HM2gH+Fp0eQe3WnajQj2DPViW4VWR/rNVl3DspHoL/EU+eEsnhcvO34Vw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-table": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-table-of-contents": { @@ -1768,82 +1664,56 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@tiptap/extension-table-row": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-table-row/-/extension-table-row-3.27.4.tgz", - "integrity": "sha512-2RTJtcy90Tc2+HpW1JyKMTwg/zz4u6hHof4CTcJS/eXLk3g1L60DDzYvz4GzBxQvsOT3QVcbaU9QOk9A3Sx7Fg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extension-table": "3.27.4" - } - }, "node_modules/@tiptap/extension-text": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.27.4.tgz", - "integrity": "sha512-lKQH/hP4FBXsziHypd6Ywj8JFvMLM5GVkK1xsH6yApNuXbHq95rd42ZOYWpYILIBib7tlaz93z61d74UrJiuiw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.28.0.tgz", + "integrity": "sha512-Jqp1LgfY1mnp4TQHoy+vnHyfn6qnnAM6nHgGwbY5zA8b/Xf1Etll6pziTx9p1J1qcfAgSrnjOyAmA++H7VQqww==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-text-align": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-align/-/extension-text-align-3.27.4.tgz", - "integrity": "sha512-ArfL7GLOXCSmrmiBiaWwAf7RPHXDdxLGPru29qKDLQDthjXcNOdlwlPBbolRu7mXwlSmaYpqWGrG75/AEat3mw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-align/-/extension-text-align-3.28.0.tgz", + "integrity": "sha512-P+KQMTtCpLFq9dRvy/fbyu2BGALulT/1HtsLbpFG4tKATaKa+cFnJ7rLZF7b3AdAMDNtXdnivxAtixAMBt2QWw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-text-style": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-style/-/extension-text-style-3.27.4.tgz", - "integrity": "sha512-Wmj64TQXY85gc7lUNbubW32sDCnVOJlpGraMeARRC9Z0EKBX1JpAxddo/64F17bn3kzLxXVszBFyLRcNgTdU2g==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-style/-/extension-text-style-3.28.0.tgz", + "integrity": "sha512-UreMyqZSv770+CXJK/l1qeHUdaNMQAVkxSOPWpiY9FiT8B4OQyjA7WBOlWrzbvVTzfJvXWk2Y3SWgVRx+/Cd9A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" - } - }, - "node_modules/@tiptap/extension-typography": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-typography/-/extension-typography-3.27.4.tgz", - "integrity": "sha512-Sn4HZOKpcFR0m7msSGWNEunRobiXk8bs78e8+qJHa3yoxPfXsFoZySEgbD6TO7JjzQtVG927FQLCXQTzK7Q0Tg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.27.4.tgz", - "integrity": "sha512-nRJGvRyEXDtINlHTW+C2oWcL3vmX1URVxAPpkD3Zwn5Rb/vEeOU/pk/w97I0iid816MR4iVbvl1XhbUVegK9gQ==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.28.0.tgz", + "integrity": "sha512-rwxCS6vTh2DJkNIYQX7JSrrRSmm76e2y48aZeuZO5ShkvLWMuJ3/zqDHRxAQVDiJhuE2Cp8NrTmPYlUc9YrT0A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4" + "@tiptap/core": "3.28.0" } }, "node_modules/@tiptap/extension-unique-id": { @@ -1877,23 +1747,23 @@ } }, "node_modules/@tiptap/extensions": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.27.4.tgz", - "integrity": "sha512-d8opkg2iGtVwJmNGIqv0blfRxnvWOJp1brz+Z8CsP4ojSS2ZtaE46d6JSQ5OeJ7nMpjhT+9wh4UQcA7OSEO59w==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.28.0.tgz", + "integrity": "sha512-DJT1khCK+O/pT1gQlAnoKAx6zwDkgv7GtnhSfkqm1/4KHC3x+SSJnBIgBl9oGHymA+DxWbW1EULU4V3d/kT2uQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4" + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0" } }, "node_modules/@tiptap/pm": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.27.4.tgz", - "integrity": "sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.28.0.tgz", + "integrity": "sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.4.1", @@ -1916,35 +1786,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.27.4.tgz", - "integrity": "sha512-/sb6rFxNt5BO4hWpUwvHh+Yh1kNyCQuuz3oDpGef5HUUjSdu9p9rfNiHWIUKBadK8VXuw5es7N+UlZ4hma+gvA==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.28.0.tgz", + "integrity": "sha512-pqvFpValV+ONtlu3l4s73ROm/tEjoGMmt6uHmSJaLbqTcMHkdWuR3flJ/5brr4IDHe1foxmNicMbRxlje7yBYw==", "license": "MIT", "dependencies": { - "@tiptap/core": "^3.27.4", - "@tiptap/extension-blockquote": "^3.27.4", - "@tiptap/extension-bold": "^3.27.4", - "@tiptap/extension-bullet-list": "^3.27.4", - "@tiptap/extension-code": "^3.27.4", - "@tiptap/extension-code-block": "^3.27.4", - "@tiptap/extension-document": "^3.27.4", - "@tiptap/extension-dropcursor": "^3.27.4", - "@tiptap/extension-gapcursor": "^3.27.4", - "@tiptap/extension-hard-break": "^3.27.4", - "@tiptap/extension-heading": "^3.27.4", - "@tiptap/extension-horizontal-rule": "^3.27.4", - "@tiptap/extension-italic": "^3.27.4", - "@tiptap/extension-link": "^3.27.4", - "@tiptap/extension-list": "^3.27.4", - "@tiptap/extension-list-item": "^3.27.4", - "@tiptap/extension-list-keymap": "^3.27.4", - "@tiptap/extension-ordered-list": "^3.27.4", - "@tiptap/extension-paragraph": "^3.27.4", - "@tiptap/extension-strike": "^3.27.4", - "@tiptap/extension-text": "^3.27.4", - "@tiptap/extension-underline": "^3.27.4", - "@tiptap/extensions": "^3.27.4", - "@tiptap/pm": "^3.27.4" + "@tiptap/core": "^3.28.0", + "@tiptap/extension-blockquote": "^3.28.0", + "@tiptap/extension-bold": "^3.28.0", + "@tiptap/extension-bullet-list": "^3.28.0", + "@tiptap/extension-code": "^3.28.0", + "@tiptap/extension-code-block": "^3.28.0", + "@tiptap/extension-document": "^3.28.0", + "@tiptap/extension-dropcursor": "^3.28.0", + "@tiptap/extension-gapcursor": "^3.28.0", + "@tiptap/extension-hard-break": "^3.28.0", + "@tiptap/extension-heading": "^3.28.0", + "@tiptap/extension-horizontal-rule": "^3.28.0", + "@tiptap/extension-italic": "^3.28.0", + "@tiptap/extension-link": "^3.28.0", + "@tiptap/extension-list": "^3.28.0", + "@tiptap/extension-list-item": "^3.28.0", + "@tiptap/extension-list-keymap": "^3.28.0", + "@tiptap/extension-ordered-list": "^3.28.0", + "@tiptap/extension-paragraph": "^3.28.0", + "@tiptap/extension-strike": "^3.28.0", + "@tiptap/extension-text": "^3.28.0", + "@tiptap/extension-underline": "^3.28.0", + "@tiptap/extensions": "^3.28.0", + "@tiptap/pm": "^3.28.0" }, "funding": { "type": "github", @@ -1966,22 +1836,22 @@ } }, "node_modules/@tiptap/vue-3": { - "version": "3.27.4", - "resolved": "https://registry.npmmirror.com/@tiptap/vue-3/-/vue-3-3.27.4.tgz", - "integrity": "sha512-fTz21viWZlpZz7PPdHJde+kzYmgRXBivVLoC2KGEeltqsREa8JR0v/eUDbzeMjj6AIajlNDtmn96I3h14gZJLg==", + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/@tiptap/vue-3/-/vue-3-3.28.0.tgz", + "integrity": "sha512-twb3T6hofM0ajncCSgrLYIjfn86WfR2NVXteurdNOcnkJCIMTij5OhO/xn0z7y8+lp3TMkF6H5kajUpivBpnzw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.27.4", - "@tiptap/extension-floating-menu": "^3.27.4" + "@tiptap/extension-bubble-menu": "^3.28.0", + "@tiptap/extension-floating-menu": "^3.28.0" }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "3.27.4", - "@tiptap/pm": "3.27.4", + "@tiptap/core": "3.28.0", + "@tiptap/pm": "3.28.0", "vue": "^3.0.0" } }, @@ -7229,7 +7099,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/platform/package.json b/platform/package.json index f4ee1bb..1dfa4e5 100644 --- a/platform/package.json +++ b/platform/package.json @@ -11,6 +11,14 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@tiptap/extension-color": "^3.28.0", + "@tiptap/extension-image": "^3.28.0", + "@tiptap/extension-link": "^3.28.0", + "@tiptap/extension-text-align": "^3.28.0", + "@tiptap/extension-text-style": "^3.28.0", + "@tiptap/extension-underline": "^3.28.0", + "@tiptap/starter-kit": "^3.28.0", + "@tiptap/vue-3": "^3.28.0", "@umoteam/editor": "^10.2.1", "axios": "^1.13.1", "chart": "^0.1.2", diff --git a/platform/src/views/dashboard/index.vue b/platform/src/views/dashboard/index.vue index 9e83654..e1bcac7 100644 --- a/platform/src/views/dashboard/index.vue +++ b/platform/src/views/dashboard/index.vue @@ -379,9 +379,9 @@ onMounted(async () => { .welcome-section { margin-bottom: 16px; padding: 32px; - background: linear-gradient(135deg, #3973ff 0%, #4f84ff 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; - box-shadow: 0 4px 12px rgba(6, 45, 163, 0.2); + box-shadow: 0 4px 20px rgba(102, 126, 234, 0.25); .welcome-content { .welcome-title { @@ -408,63 +408,65 @@ onMounted(async () => { .stat-card { background: var(--el-bg-color); - border-radius: 12px; - padding: 24px; + border-radius: 16px; + padding: 28px; display: flex; align-items: center; - gap: 16px; + gap: 20px; border: 1px solid var(--el-border-color-lighter); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); - transition: all 0.3s; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; position: relative; overflow: hidden; &:hover { - transform: translateY(-4px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-6px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12); border-color: var(--el-color-primary-light-7); } .stat-icon-wrapper { - width: 56px; - height: 56px; - border-radius: 12px; + width: 60px; + height: 60px; + border-radius: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); .el-icon { color: #fff; + font-size: 28px; } } &.income .stat-icon-wrapper { - background: linear-gradient(135deg, #3973ff 0%, #4f84ff 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } &.users .stat-icon-wrapper { - background: linear-gradient(135deg, #10b981 0%, #34d399 100%); + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); } &.orders .stat-icon-wrapper { - background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); } &.active .stat-icon-wrapper { - background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%); + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); } &.knowledge .stat-icon-wrapper { - background: linear-gradient(135deg, #3973ff 0%, #4f84ff 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } &.employees .stat-icon-wrapper { - background: linear-gradient(135deg, #10b981 0%, #34d399 100%); + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); } &.tenants .stat-icon-wrapper { - background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%); + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); } .stat-content { @@ -692,15 +694,16 @@ onMounted(async () => { align-items: center; justify-content: center; color: #fff; + font-weight: 500; &.operation { - background-color: rgba(79, 132, 255, 0.2); - color: #4f84ff; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; } &.access { - background-color: rgba(85, 190, 130, 0.2); - color: #55be82; + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); + color: #fff; } } diff --git a/platform/vite.config.js b/platform/vite.config.js index a82e8a7..ee05411 100644 --- a/platform/vite.config.js +++ b/platform/vite.config.js @@ -29,14 +29,14 @@ export default defineConfig({ server: { host: "127.0.0.1", port: 4000, - // 开发时前端在 5000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=8081) + // 开发时前端在 4000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=9000) proxy: { "/platform": { - target: "http://127.0.0.1:8081", + target: "http://127.0.0.1:9000", changeOrigin: true, }, "/backend": { - target: "http://127.0.0.1:8081", + target: "http://127.0.0.1:9000", changeOrigin: true, }, }, diff --git a/uniapp/manifest.json b/uniapp/manifest.json index ad7fa81..64600f2 100644 --- a/uniapp/manifest.json +++ b/uniapp/manifest.json @@ -1,13 +1,13 @@ { "name" : "uniapp", - "appid" : "", + "appid" : "__UNI__D80959E", "description" : "", "versionName" : "1.0.0", "versionCode" : "100", "transformPx" : false, - "uniStatistics": { - "enable": false, - "debug": false + "uniStatistics" : { + "enable" : false, + "debug" : false }, /* 5+App特有相关 */ "app-plus" : { @@ -57,7 +57,9 @@ "mp-weixin" : { "appid" : "", "setting" : { - "urlCheck" : false + "urlCheck" : false, + "minified" : true, + "postcss" : true }, "usingComponents" : true, "mergeVirtualHostAttributes" : true @@ -74,5 +76,8 @@ "usingComponents" : true, "mergeVirtualHostAttributes" : true }, - "vueVersion" : "3" + "vueVersion" : "3", + "h5" : { + "title" : "云泽系统" + } } diff --git a/uniapp/pages/dashboard/dashboard.vue b/uniapp/pages/dashboard/dashboard.vue index 88fc78e..2bdd7c6 100644 --- a/uniapp/pages/dashboard/dashboard.vue +++ b/uniapp/pages/dashboard/dashboard.vue @@ -1,17 +1,8 @@