package web import ( "fmt" "net/http" "sort" "time" "daili/internal/domain" "daili/internal/store" ) // DayCell 是日历格。 type DayCell struct { Date string Day int InMonth bool IsToday bool IsWeekend bool IsSelected bool IsPast bool DueCount int TaskCount int DoneCount int Heat int Tickets []Ticket More int } // Ticket 是日历格里的色签。 type Ticket struct { SubID int64 Tail string Name string Pkg string Color string Done bool Skipped bool IsDue bool } // TaskRow 是当日待打清单的一行。 type TaskRow struct { SubView CheckinID int64 Done bool Skipped bool Note string Operator string } type calendarData struct { Year, Month int Title string PrevY, PrevM int NextY, NextM int TodayStr string Weekdays []string Weeks [][]DayCell Selected string SelectedLabel string SelectedIsToday bool SelectedIsPast bool Tasks []TaskRow Due []SubView OtherDue []SubView Extend *ExtendPanel MonthDue int MonthTasks int MonthDone int NoPackages bool NoCustomers bool Back string } // calSub 是日历计算用的订阅上下文。 type calSub struct { sub store.Subscription start time.Time end time.Time hasEnd bool eval domain.Eval pauses []store.Pause } func (c calSub) pausedOn(date string) bool { for _, p := range c.pauses { if p.StartDate == "" { continue } if date >= p.StartDate && (p.EndDate == "" || date < p.EndDate) { return true } } return false } func (c calSub) covers(date string) bool { if c.sub.Status == domain.StatusClosed || !c.hasEnd { return false } if date < c.sub.StartDate || date > c.sub.EndDate { return false } if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 { return false } return !c.pausedOn(date) } func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { today := domain.Today() y := s.queryInt(r, "y", today.Year()) m := s.queryInt(r, "m", int(today.Month())) if m < 1 || m > 12 { y, m = today.Year(), int(today.Month()) } monthStart := domain.MonthStart(y, time.Month(m)) monthEnd := domain.MonthEnd(y, time.Month(m)) sel := r.URL.Query().Get("d") selT, err := domain.ParseDate(sel) if sel != "" && err != nil { sel = "" } if sel == "" { if today.After(monthEnd) || today.Before(monthStart) { sel = domain.Date(monthStart) } else { sel = domain.Date(today) } selT = domain.ParseDateOr(sel, today) } // 网格:以周一为首日,铺满整月所需的周数 gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart)) weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7 gridEnd := domain.AddDays(gridStart, weeks*7-1) subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd)) if err != nil { http.Error(w, "读取订阅失败", http.StatusInternalServerError) return } ids := make([]int64, 0, len(subs)) for _, sub := range subs { ids = append(ids, sub.ID) } pauses, err := s.db.PausesForSubs(ids) if err != nil { http.Error(w, "读取暂停记录失败", http.StatusInternalServerError) return } checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd)) if err != nil { http.Error(w, "读取打卡记录失败", http.StatusInternalServerError) return } warn := s.warnDays() list := make([]calSub, 0, len(subs)) for _, sub := range subs { cs := calSub{ sub: sub, start: domain.ParseDateOr(sub.StartDate, today), eval: sub.Eval(today, warn), pauses: pauses[sub.ID], } if sub.EndDate != "" { cs.end = domain.ParseDateOr(sub.EndDate, today) cs.hasEnd = true } list = append(list, cs) } data := &calendarData{ Year: y, Month: m, Title: fmt.Sprintf("%d 年 %d 月", y, m), TodayStr: domain.Date(today), Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, Selected: sel, SelectedIsToday: sel == domain.Date(today), Back: r.URL.RequestURI(), } data.PrevY, data.PrevM = m-1, y if data.PrevM < 1 { data.PrevM = 12 data.PrevY = y - 1 } data.NextY, data.NextM = y, m+1 if data.NextM > 12 { data.NextM = 1 data.NextY = y + 1 } label := selT.Format("1 月 2 日") data.SelectedLabel = label + " 周" + dowCN(sel) data.SelectedIsPast = selT.Before(today) // 铺格子 for wi := 0; wi < weeks; wi++ { week := make([]DayCell, 0, 7) for di := 0; di < 7; di++ { day := domain.AddDays(gridStart, wi*7+di) ds := domain.Date(day) cell := DayCell{ Date: ds, Day: day.Day(), InMonth: day.Month() == time.Month(m), IsToday: ds == domain.Date(today), IsWeekend: di >= 5, IsSelected: ds == sel, IsPast: day.Before(today), } tickets := []Ticket{} for _, cs := range list { if cs.sub.EndDate == ds { cell.DueCount++ } if !cell.InMonth || !cs.covers(ds) { continue } cell.TaskCount++ ci := checkins[cs.sub.ID][ds] t := Ticket{ SubID: cs.sub.ID, Tail: cs.sub.CustomerTail, Name: cs.sub.CustomerName, Pkg: shortName(cs.sub.PackageName), Color: cs.sub.PackageColor, Done: ci.Status == "done", Skipped: ci.Status == "skipped", IsDue: cs.sub.EndDate == ds, } if t.Done || t.Skipped { cell.DoneCount++ } tickets = append(tickets, t) } sort.SliceStable(tickets, func(i, j int) bool { if tickets[i].Done != tickets[j].Done { return !tickets[i].Done } return tickets[i].SubID < tickets[j].SubID }) if len(tickets) > 3 { cell.More = len(tickets) - 3 tickets = tickets[:3] } cell.Tickets = tickets cell.Heat = heatLevel(cell.TaskCount + cell.DueCount) if cell.InMonth { data.MonthDue += cell.DueCount data.MonthTasks += cell.TaskCount data.MonthDone += cell.DoneCount } week = append(week, cell) } data.Weeks = append(data.Weeks, week) } // 当日清单 due := []SubView{} tasks := []TaskRow{} for _, cs := range list { if cs.sub.EndDate == sel { due = append(due, newSubView(cs.sub, today, warn)) } if !cs.covers(sel) { continue } v := newSubView(cs.sub, today, warn) row := TaskRow{SubView: v} if ci, ok := checkins[cs.sub.ID][sel]; ok { row.CheckinID = ci.ID row.Done = ci.Status == "done" row.Skipped = ci.Status == "skipped" row.Note = ci.Note row.Operator = ci.OperatorName } tasks = append(tasks, row) } sort.SliceStable(tasks, func(i, j int) bool { ai := tasks[i].Done || tasks[i].Skipped aj := tasks[j].Done || tasks[j].Skipped if ai != aj { return !ai } return tasks[i].EndDate < tasks[j].EndDate }) data.Tasks = tasks data.Due = due // 其它日期到期(不在当天的 7 天内) if upcoming, err := s.db.RecentDue(domain.Date(today), 7); err == nil { for _, u := range upcoming { if u.EndDate == sel { continue } data.OtherDue = append(data.OtherDue, newSubView(u, today, warn)) } } // 追加面板 if eid := s.queryInt(r, "extend", 0); eid > 0 { if sub, err := s.db.Sub(int64(eid)); err == nil { if pkg, err := s.db.Package(sub.PackageID); err == nil { back := r.URL.Path + "?" + r.URL.RawQuery data.Extend = buildExtendPanel(sub, pkg, today, back, warn) } } } // 空态提示 if n, _ := s.db.CustomerCount(); n == 0 { data.NoCustomers = true } if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 { data.NoPackages = true } p := s.newPage(r, "日历", "calendar") p.D = data s.render(w, r, "calendar", p) } func heatLevel(n int) int { switch { case n <= 0: return 0 case n <= 2: return 1 case n <= 5: return 2 case n <= 9: return 3 default: return 4 } } // shortName 取套餐名前两个字符,用作日历色签上的短标签。 func shortName(s string) string { r := []rune(s) if len(r) <= 2 { return s } return string(r[:2]) }