package controllers import ( "encoding/json" "fmt" "io" "regexp" "strconv" "strings" "time" "unicode/utf8" "server/models" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendOaScheduleController OA日程管理(日历待办)。 // 日程数据按 JWT 中的租户与用户双重隔离,仅创建者本人可见与操作。 type BackendOaScheduleController struct { beego.Controller } func (c *BackendOaScheduleController) oaScheduleClaims() (*jwtutil.Claims, error) { auth := c.Ctx.Request.Header.Get("Authorization") if auth == "" { return nil, orm.ErrNoRows } parts := strings.SplitN(auth, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { return nil, orm.ErrNoRows } claims, err := jwtutil.ParseToken(parts[1]) if err != nil { return nil, err } if claims.UserType != "backend" { return nil, orm.ErrNoRows } return claims, nil } func (c *BackendOaScheduleController) oasJsonErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } func (c *BackendOaScheduleController) oasOk(data interface{}) { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} _ = c.ServeJSON() } var ( oasDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) oasTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`) ) // isValidOaDate 校验 "YYYY-MM-DD" 日期字符串(含真实日历合法性,如 2 月 30 日)。 func isValidOaDate(s string) bool { if !oasDatePattern.MatchString(s) { return false } _, err := time.ParseInLocation("2006-01-02", s, time.Local) return err == nil } // isValidOaTime 校验 "HH:mm" 时间字符串;HH:mm 定长数字串的字典序即时间序。 func isValidOaTime(s string) bool { if !oasTimePattern.MatchString(s) { return false } _, err := time.ParseInLocation("15:04", s, time.Local) return err == nil } // normalizeOaDate 将 ORM 读出的日期值统一为 "YYYY-MM-DD"。 // DSN 开启 parseTime=True 后 DATE 列会被驱动解析为 time.Time, // 赋给 string 字段后是 Go 默认格式("2026-08-29 00:00:00 +0800 CST"),取前 10 位。 func normalizeOaDate(s string) string { s = strings.TrimSpace(s) if len(s) >= 10 { return s[:10] } return s } // oaScheduleFilter 日程列表筛选条件,用于构造 ORM 查询条件。 // 指针式的 Has*/On* 标记用于区分"未传参"与"传了默认值 0/空串"。 type oaScheduleFilter struct { Keyword string Priority int8 HasPriority bool StartDate string EndDate string OnDate string Status int8 HasStatus bool Ongoing bool } // oaScheduleCond 构造日程查询条件,始终按租户 + 用户双重隔离。 // Ongoing 表示"进行中":跨天顺延中的任务,即被顺延出去的源日程(carry_over=1) // 或由前序日程顺延而来的续做日程(source_id>0)。 func oaScheduleCond(tid int, userID int, f oaScheduleFilter) *orm.Condition { cond := orm.NewCondition() cond = cond.And("is_deleted", 0).And("tid", tid).And("user_id", userID) if f.Keyword != "" { cond = cond.And("title__icontains", f.Keyword) } if f.HasPriority { cond = cond.And("priority", f.Priority) } if f.StartDate != "" { cond = cond.And("schedule_date__gte", f.StartDate) } if f.EndDate != "" { cond = cond.And("schedule_date__lte", f.EndDate) } if f.OnDate != "" { cond = cond.And("schedule_date", f.OnDate) } if f.HasStatus { cond = cond.And("status", f.Status) } if f.Ongoing { sub := orm.NewCondition() sub = sub.Or("source_id__gt", 0).Or("carry_over", 1) cond = cond.AndCond(sub) } return cond } func oaScheduleQS(tid int, userID int, f oaScheduleFilter) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaSchedule)). SetCond(oaScheduleCond(tid, userID, f)) } // List GET /backend/oa/schedule/list // 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选, // 以及 category 待办分类:all-全部 today-今日 pending-未完成 ongoing-进行中 done-已完成。 // 返回值额外带 counts(各分类数量),供页面 tabs 展示角标。 // 日历视图传 start_date/end_date 且 pageSize 足够大时一次拉取区间全量数据。 func (c *BackendOaScheduleController) List() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) keyword := strings.TrimSpace(c.GetString("keyword")) status := strings.TrimSpace(c.GetString("status")) priority := strings.TrimSpace(c.GetString("priority")) startDate := strings.TrimSpace(c.GetString("start_date")) endDate := strings.TrimSpace(c.GetString("end_date")) category := strings.TrimSpace(c.GetString("category")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 20 } base := oaScheduleFilter{Keyword: keyword} if status == "0" || status == "1" { statusVal, _ := strconv.Atoi(status) base.HasStatus = true base.Status = int8(statusVal) } if priority == "0" || priority == "1" || priority == "2" { priorityVal, _ := strconv.Atoi(priority) base.HasPriority = true base.Priority = int8(priorityVal) } // 分类角标按全量统计(只受关键词/优先级影响),不随日历翻月或区间筛选跳动 countOf := func(f oaScheduleFilter) int64 { n, _ := oaScheduleQS(claims.TenantId, claims.UserID, f).Count() return n } today := time.Now().Format("2006-01-02") counts := map[string]interface{}{ "all": countOf(base), "today": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, OnDate: today}), "pending": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 0}), "ongoing": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, Ongoing: true}), "done": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 1}), } // 列表数据 = 基础筛选 + 日期区间 + 当前分类 listFilter := base if isValidOaDate(startDate) { listFilter.StartDate = startDate } if isValidOaDate(endDate) { listFilter.EndDate = endDate } switch category { case "today": listFilter.OnDate = today case "pending": listFilter.HasStatus = true listFilter.Status = 0 case "ongoing": listFilter.Ongoing = true case "done": listFilter.HasStatus = true listFilter.Status = 1 } qs := oaScheduleQS(claims.TenantId, claims.UserID, listFilter) total, _ := qs.Count() var list []models.OaSchedule _, err = qs.OrderBy("schedule_date", "start_time", "-id"). Limit(pageSize).Offset((page - 1) * pageSize). All(&list) if err != nil && err != orm.ErrNoRows { c.oasJsonErr(500, 500, "查询失败") return } if list == nil { list = []models.OaSchedule{} } // 统一日期格式,前端日历分组与日期标签均依赖 "YYYY-MM-DD"。 for i := range list { list[i].ScheduleDate = normalizeOaDate(list[i].ScheduleDate) } c.oasOk(map[string]interface{}{ "list": list, "total": total, "counts": counts, }) } // Stats GET /backend/oa/schedule/stats // 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。 func (c *BackendOaScheduleController) Stats() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } now := time.Now() today := now.Format("2006-01-02") // 本周从周一开始 weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } weekStart := now.AddDate(0, 0, -(weekday - 1)).Format("2006-01-02") weekEnd := now.AddDate(0, 0, 7-weekday).Format("2006-01-02") monthStart := now.Format("2006-01") + "-01" base := func() orm.QuerySeter { return models.Orm.QueryTable(new(models.OaSchedule)). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID) } todayTotal, _ := base().Filter("schedule_date", today).Count() todayDone, _ := base().Filter("schedule_date", today).Filter("status", 1).Count() weekTotal, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Count() weekDone, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Filter("status", 1).Count() monthTotal, _ := base().Filter("schedule_date__gte", monthStart).Count() monthDone, _ := base().Filter("schedule_date__gte", monthStart).Filter("status", 1).Count() overdue, _ := base().Filter("schedule_date__lt", today).Filter("status", 0).Count() c.oasOk(map[string]interface{}{ "today_total": todayTotal, "today_done": todayDone, "week_total": weekTotal, "week_done": weekDone, "month_total": monthTotal, "month_done": monthDone, "overdue": overdue, }) } type oaSchedulePayload struct { Title string `json:"title"` Content string `json:"content"` ScheduleDate string `json:"schedule_date"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` AllDay int8 `json:"all_day"` Color string `json:"color"` Priority int8 `json:"priority"` // 提醒绑定:开启后会在提醒工具生成一条提醒任务,并把其 ID 回写到 ReminderID。 ReminderEnabled bool `json:"reminder_enabled"` ReminderTime string `json:"reminder_time"` RemindChannels []string `json:"remind_channels"` AdvanceMinutes int `json:"advance_minutes"` } // oaReminderChannels 允许的提醒渠道集合。 var oaReminderChannels = map[string]bool{ "SMS": true, "EMAIL": true, "BARK": true, "SITE_MSG": true, } // createOaScheduleReminder 为 OA 日程创建一条绑定的提醒任务。 // 复用提醒工具的数据模型(yz_backend_schedule + yz_backend_schedule_reminder): // 生成一条 BackendSchedule,再按渠道逐条生成提醒记录,返回 BackendSchedule.ID // 作为 OA 日程的 reminder_id。EMAIL/BARK 渠道会附带确认回执令牌与重复发送配置。 func createOaScheduleReminder(tid int, userID uint64, title, content, reminderTime string, channels []string, advanceMinutes int) (uint64, error) { if strings.TrimSpace(reminderTime) == "" { return 0, fmt.Errorf("提醒时间不能为空") } schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", reminderTime, time.Local) if err != nil { return 0, fmt.Errorf("提醒时间格式不合法") } if advanceMinutes < 0 { advanceMinutes = 0 } title = strings.TrimSpace(title) if title == "" { title = "日程提醒" } bs := models.BackendSchedule{ Tid: tid, Title: title, Content: content, ScheduleTime: schedTime, UserID: userID, } schedID, err := models.Orm.Insert(&bs) if err != nil { return 0, fmt.Errorf("创建提醒任务失败: %v", err) } now := time.Now() for _, ch := range channels { ch = strings.ToUpper(strings.TrimSpace(ch)) if !oaReminderChannels[ch] { continue } firstSend := schedTime.Add(-time.Duration(advanceMinutes) * time.Minute) reminder := models.BackendScheduleReminder{ Tid: tid, ScheduleID: uint64(schedID), RemindChannel: ch, AdvanceMinutes: advanceMinutes, NextRemindTime: firstSend, ReceiverUserID: userID, RemindStatus: 0, CreateTime: now, UpdateTime: now, } if ch == "EMAIL" || ch == "BARK" { token := backendGenerateToken() reminder.AckToken = &token reminder.RepeatIntervalMinutes = 10 reminder.MaxSendCount = 5 } else { reminder.RepeatIntervalMinutes = 0 reminder.MaxSendCount = 1 } if _, err := models.Orm.Insert(&reminder); err != nil { return 0, fmt.Errorf("创建提醒失败: %v", err) } } return uint64(schedID), nil } // deleteBoundReminder 软删除 OA 日程绑定的提醒任务及其提醒记录。 // 删除日程或关闭提醒时使用,避免提醒工具里残留孤立任务。 func deleteBoundReminder(scheduleID uint64) { if scheduleID == 0 { return } _, _ = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", scheduleID).Delete() _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", scheduleID). Update(map[string]interface{}{"IsDeleted": 1, "UpdateTime": time.Now()}) } // oaScheduleImage 日程相关图片(截图/照片),只存文件引用,不存二进制。 type oaScheduleImage struct { ID uint64 `json:"id"` URL string `json:"url"` Name string `json:"name"` } const ( oaScheduleMaxImages = 20 oaScheduleMaxImageURL = 500 oaScheduleMaxImageName = 120 ) // normalizeOaScheduleImages 校验并序列化日程图片列表: // 丢弃无 url 或超长的条目,条目数超限时返回 false。 func normalizeOaScheduleImages(list []oaScheduleImage) (string, bool) { if len(list) > oaScheduleMaxImages { return "", false } out := make([]oaScheduleImage, 0, len(list)) for _, img := range list { img.URL = strings.TrimSpace(img.URL) if img.URL == "" || len(img.URL) > oaScheduleMaxImageURL { continue } img.Name = strings.TrimSpace(img.Name) if utf8.RuneCountInString(img.Name) > oaScheduleMaxImageName { img.Name = string([]rune(img.Name)[:oaScheduleMaxImageName]) } out = append(out, img) } raw, err := json.Marshal(out) if err != nil { return "", false } return string(raw), true } // parseOaScheduleImages 解析 images 列;脏数据返回空数组,不阻断详情展示。 func parseOaScheduleImages(raw string) []oaScheduleImage { list := make([]oaScheduleImage, 0) _ = json.Unmarshal([]byte(raw), &list) return list } // parseOaSchedulePayload 读取并校验新建/编辑日程的请求体;校验失败时直接输出错误响应。 func (c *BackendOaScheduleController) parseOaSchedulePayload() (oaSchedulePayload, bool) { var payload oaSchedulePayload raw, err := io.ReadAll(c.Ctx.Request.Body) if err != nil || json.Unmarshal(raw, &payload) != nil { c.oasJsonErr(400, 400, "参数错误") return payload, false } payload.Title = strings.TrimSpace(payload.Title) if payload.Title == "" { c.oasJsonErr(400, 400, "请输入日程标题") return payload, false } if utf8.RuneCountInString(payload.Title) > 80 { c.oasJsonErr(400, 400, "日程标题不能超过80字") return payload, false } if !isValidOaDate(payload.ScheduleDate) { c.oasJsonErr(400, 400, "日程日期无效") return payload, false } if payload.AllDay != 0 { payload.AllDay = 1 payload.StartTime = "" payload.EndTime = "" } else { if !isValidOaTime(payload.StartTime) { c.oasJsonErr(400, 400, "开始时间无效") return payload, false } if payload.EndTime != "" { if !isValidOaTime(payload.EndTime) { c.oasJsonErr(400, 400, "结束时间无效") return payload, false } if payload.EndTime < payload.StartTime { c.oasJsonErr(400, 400, "结束时间不能早于开始时间") return payload, false } } } if payload.Priority < 0 || payload.Priority > 2 { payload.Priority = 0 } payload.Color = strings.TrimSpace(payload.Color) if payload.Color == "" { payload.Color = "#409EFF" } // 提醒绑定校验:开启提醒时必须选择渠道且提醒时间合法。 if payload.ReminderEnabled { validChannels := make([]string, 0, len(payload.RemindChannels)) for _, ch := range payload.RemindChannels { ch = strings.ToUpper(strings.TrimSpace(ch)) if oaReminderChannels[ch] { validChannels = append(validChannels, ch) } } payload.RemindChannels = validChannels if len(validChannels) == 0 { c.oasJsonErr(400, 400, "请至少选择一个提醒渠道") return payload, false } if _, err := time.ParseInLocation("2006-01-02 15:04:05", payload.ReminderTime, time.Local); err != nil { c.oasJsonErr(400, 400, "提醒时间格式不合法,支持 YYYY-MM-DD HH:mm:ss") return payload, false } if payload.AdvanceMinutes < 0 { payload.AdvanceMinutes = 0 } } return payload, true } // Create POST /backend/oa/schedule/create func (c *BackendOaScheduleController) Create() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } payload, ok := c.parseOaSchedulePayload() if !ok { return } now := time.Now() item := &models.OaSchedule{ Tid: claims.TenantId, UserID: uint64(claims.UserID), UserName: claims.Username, Title: payload.Title, Content: payload.Content, ScheduleDate: payload.ScheduleDate, StartTime: payload.StartTime, EndTime: payload.EndTime, AllDay: payload.AllDay, Color: payload.Color, Priority: payload.Priority, Status: 0, IsDeleted: 0, CreateTime: now, UpdateTime: &now, } if _, err := models.Orm.Insert(item); err != nil { c.oasJsonErr(500, 500, "保存失败") return } // 开启提醒:生成绑定的提醒任务,并把其 ID 回写到日程。 if payload.ReminderEnabled { rid, rerr := createOaScheduleReminder( claims.TenantId, uint64(claims.UserID), payload.Title, payload.Content, payload.ReminderTime, payload.RemindChannels, payload.AdvanceMinutes, ) if rerr != nil { // 回滚已创建的日程,避免产生孤立日程 _, _ = models.Orm.QueryTable(new(models.OaSchedule)).Filter("id", item.ID).Delete() c.oasJsonErr(500, 500, rerr.Error()) return } item.ReminderID = rid if _, err := models.Orm.Update(item, "ReminderID"); err != nil { c.oasJsonErr(500, 500, "保存失败") return } } c.oasOk(item) } // Update POST /backend/oa/schedule/update/:id func (c *BackendOaScheduleController) Update() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } payload, ok := c.parseOaSchedulePayload() if !ok { return } var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在") return } now := time.Now() item.Title = payload.Title item.Content = payload.Content item.ScheduleDate = payload.ScheduleDate item.StartTime = payload.StartTime item.EndTime = payload.EndTime item.AllDay = payload.AllDay item.Color = payload.Color item.Priority = payload.Priority item.UpdateTime = &now // 重新绑定提醒:先创建新提醒,成功后再删除旧提醒(关闭提醒则清掉绑定)。 // 顺序反过来可避免"新提醒创建失败却删掉了原提醒"导致提醒丢失。 if payload.ReminderEnabled { rid, rerr := createOaScheduleReminder( claims.TenantId, uint64(claims.UserID), payload.Title, payload.Content, payload.ReminderTime, payload.RemindChannels, payload.AdvanceMinutes, ) if rerr != nil { c.oasJsonErr(500, 500, rerr.Error()) return } if item.ReminderID > 0 { deleteBoundReminder(item.ReminderID) } item.ReminderID = rid } else if item.ReminderID > 0 { deleteBoundReminder(item.ReminderID) item.ReminderID = 0 } if _, err := models.Orm.Update(&item, "Title", "Content", "ScheduleDate", "StartTime", "EndTime", "AllDay", "Color", "Priority", "ReminderID", "UpdateTime"); err != nil { c.oasJsonErr(500, 500, "保存失败") return } c.oasOk(item) } // SetImages POST /backend/oa/schedule/images/:id // 单独维护日程的相关图片(文件已先上传到文件管理,这里只存引用)。 // 与 Update 分开,避免编辑标题/时间等基础信息时把图片清空。 func (c *BackendOaScheduleController) SetImages() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } var payload struct { Images []oaScheduleImage `json:"images"` } raw, _ := io.ReadAll(c.Ctx.Request.Body) if len(raw) > 0 && json.Unmarshal(raw, &payload) != nil { c.oasJsonErr(400, 400, "参数错误") return } imagesJSON, ok := normalizeOaScheduleImages(payload.Images) if !ok { c.oasJsonErr(400, 400, "图片数量过多") return } var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在") return } now := time.Now() item.Images = imagesJSON item.UpdateTime = &now if _, err := models.Orm.Update(&item, "Images", "UpdateTime"); err != nil { c.oasJsonErr(500, 500, "保存失败") return } c.oasOk(map[string]interface{}{"images": parseOaScheduleImages(imagesJSON)}) } // UpdateContent POST /backend/oa/schedule/content/:id 仅更新备注内容。 // 与 SetImages 类似,只改写 Content 字段,不影响标题、日期、提醒等其他字段, // 因此即便日程已完成、已绑定提醒,补充备注也不会破坏既有状态。 func (c *BackendOaScheduleController) UpdateContent() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } var payload struct { Content string `json:"content"` } raw, _ := io.ReadAll(c.Ctx.Request.Body) if len(raw) > 0 && json.Unmarshal(raw, &payload) != nil { c.oasJsonErr(400, 400, "参数错误") return } var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在") return } now := time.Now() item.Content = payload.Content item.UpdateTime = &now if _, err := models.Orm.Update(&item, "Content", "UpdateTime"); err != nil { c.oasJsonErr(500, 500, "保存失败") return } c.oasOk(map[string]interface{}{"content": item.Content}) } // Delete DELETE /backend/oa/schedule/delete/:id 软删除日程。 func (c *BackendOaScheduleController) Delete() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } now := time.Now() var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在或已删除") return } // 先删除绑定的提醒任务,避免提醒工具里残留孤立任务 if item.ReminderID > 0 { deleteBoundReminder(item.ReminderID) } item.IsDeleted = 1 item.DeleteTime = &now item.UpdateTime = &now if _, err := models.Orm.Update(&item, "IsDeleted", "DeleteTime", "UpdateTime"); err != nil { c.oasJsonErr(500, 500, "删除失败") return } c.oasOk(nil) } // FinishSchedule POST /backend/oa/schedule/finish/:id // 切换完成状态:待办 -> 完成(记录完成时间);完成 -> 待办(清除完成时间)。 // 注意:方法名不能叫 Finish——Finish 是 beego Controller 的生命周期钩子, // 同名会导致路由调用后再被框架自动调用一次,toggle 逻辑被执行两遍(状态来回切)。 func (c *BackendOaScheduleController) FinishSchedule() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在") return } now := time.Now() newStatus := int8(1) var finishTime *time.Time if item.Status == 1 { newStatus = 0 } else { finishTime = &now } item.Status = newStatus item.FinishTime = finishTime item.UpdateTime = &now if _, err := models.Orm.Update(&item, "Status", "FinishTime", "UpdateTime"); err != nil { c.oasJsonErr(500, 500, "操作失败") return } c.oasOk(map[string]interface{}{"status": newStatus}) } // nextOaDate 返回 dateStr 之后的第 days 天,格式仍为 "YYYY-MM-DD"。 func nextOaDate(dateStr string, days int) string { t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) if err != nil { return "" } return t.AddDate(0, 0, days).Format("2006-01-02") } // oaScheduleRootID 取顺延链的源头日程ID:本身就是顺延出来的则沿用其来源,否则为自身。 func oaScheduleRootID(item *models.OaSchedule) uint64 { if item.SourceID > 0 { return item.SourceID } return item.ID } // carryOaSchedule 把一条待办日程顺延到目标日期: // 1) 原日程标记完成(status=1, carry_over=1, carry_to_date=目标日期); // 2) 在目标日期生成一条同内容的待办,source_id 指向顺延链源头。 // 返回新生成的日程与失败原因(空串表示成功)。 func carryOaSchedule(item *models.OaSchedule, targetDate string, claims *jwtutil.Claims, now time.Time) (*models.OaSchedule, string) { if item.Status == 1 { return nil, "该日程已完成,无需顺延" } if !isValidOaDate(targetDate) { return nil, "顺延日期无效" } if targetDate <= normalizeOaDate(item.ScheduleDate) { return nil, "顺延日期需晚于当前日程日期" } rootID := oaScheduleRootID(item) // 同一条顺延链在目标日期只允许存在一条,避免重复顺延刷出多份待办。 exist := models.Orm.QueryTable(new(models.OaSchedule)). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). Filter("source_id", rootID). Filter("schedule_date", targetDate). Exist() if exist { return nil, "该日期已存在顺延的日程" } next := &models.OaSchedule{ Tid: item.Tid, UserID: item.UserID, UserName: item.UserName, Title: item.Title, Content: item.Content, Images: item.Images, ScheduleDate: targetDate, StartTime: item.StartTime, EndTime: item.EndTime, AllDay: item.AllDay, Color: item.Color, Priority: item.Priority, Status: 0, SourceID: rootID, CarryCount: item.CarryCount + 1, IsDeleted: 0, CreateTime: now, UpdateTime: &now, } if _, err := models.Orm.Insert(next); err != nil { return nil, "生成顺延日程失败" } item.Status = 1 item.FinishTime = &now item.CarryOver = 1 item.CarryToDate = targetDate item.UpdateTime = &now if _, err := models.Orm.Update(item, "Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); err != nil { return nil, "标记原日程完成失败" } return next, "" } // CarrySchedule POST /backend/oa/schedule/carry/:id // 将单条待办顺延到指定日期(默认下一天):原日程标记完成,目标日期自动生成新待办。 func (c *BackendOaScheduleController) CarrySchedule() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.oasJsonErr(400, 400, "无效ID") return } // 目标日期可选,不传则默认顺延到下一天 var payload struct { TargetDate string `json:"target_date"` } if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 { _ = json.Unmarshal(raw, &payload) } payload.TargetDate = strings.TrimSpace(payload.TargetDate) var item models.OaSchedule err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("id", id). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). One(&item) if err != nil { c.oasJsonErr(404, 404, "日程不存在") return } targetDate := payload.TargetDate if targetDate == "" { targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1) } now := time.Now() next, msg := carryOaSchedule(&item, targetDate, claims, now) if msg != "" { c.oasJsonErr(400, 400, msg) return } c.oasOk(map[string]interface{}{ "new_id": next.ID, "schedule_date": next.ScheduleDate, "carry_count": next.CarryCount, }) } // CarryPending POST /backend/oa/schedule/carry-pending // 批量顺延:把指定日期(默认今天)全部未完成的日程一次性顺延到目标日期(默认下一天)。 // 已在目标日期存在顺延记录的会被跳过,不会重复生成。 func (c *BackendOaScheduleController) CarryPending() { claims, err := c.oaScheduleClaims() if err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } var payload struct { Date string `json:"date"` TargetDate string `json:"target_date"` } if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 { _ = json.Unmarshal(raw, &payload) } payload.Date = strings.TrimSpace(payload.Date) payload.TargetDate = strings.TrimSpace(payload.TargetDate) now := time.Now() date := payload.Date if !isValidOaDate(date) { date = now.Format("2006-01-02") } targetDate := payload.TargetDate if !isValidOaDate(targetDate) { targetDate = nextOaDate(date, 1) } if !isValidOaDate(targetDate) { c.oasJsonErr(400, 400, "顺延日期无效") return } var list []models.OaSchedule _, err = models.Orm.QueryTable(new(models.OaSchedule)). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID). Filter("schedule_date", date). Filter("status", 0). OrderBy("start_time", "id"). All(&list) if err != nil && err != orm.ErrNoRows { c.oasJsonErr(500, 500, "查询失败") return } success, skipped := 0, 0 lastDate := targetDate for i := range list { if _, msg := carryOaSchedule(&list[i], targetDate, claims, now); msg != "" { skipped++ continue } success++ lastDate = targetDate } c.oasOk(map[string]interface{}{ "success": success, "skipped": skipped, "schedule_date": lastDate, }) }