1046 lines
30 KiB
Go
1046 lines
30 KiB
Go
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,
|
||
})
|
||
}
|