package web import ( "fmt" "net/http" "net/url" "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 UrgentCnt int Heat int // PendingCount 当日还没完成的单数(= TaskCount - DoneCount) PendingCount int Tickets []Ticket More int // Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰) Strip []string } // Ticket 是日历格里的色签。 type Ticket struct { SubID int64 Tail string Name string Pkg string Color string Done bool Skipped bool IsDue bool Urgent bool // AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底) AssigneeColor string } // TaskRow 是当日待打清单的一行。 type TaskRow struct { SubView CheckinID int64 Done bool Skipped bool Note string Operator string Urgent bool } // PkgTab 是日历上方的套餐标签。 type PkgTab struct { ID int64 Name string Color string Count int } 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 TaskPending int Due []SubView OtherDue []SubView Extend *ExtendPanel MonthDue int MonthTasks int MonthDone int MonthUrgent int NoPackages bool NoCustomers bool Back string // 套餐标签(在日历中独立显示的套餐) Tabs []PkgTab PkgID int64 HasTabs bool // 快速完成:按尾号筛选出的候选 PickTail string PickRows []TaskRow PickMsg 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) } // loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 时只保留该套餐。 func (s *Server) loadCalendarSubs(from, to string, pkgID int64) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { subs, err := s.db.CalendarSubs(from, to) if err != nil { return nil, nil, nil, nil, err } if pkgID > 0 { filtered := make([]store.Subscription, 0, len(subs)) for _, sub := range subs { if sub.PackageID == pkgID { filtered = append(filtered, sub) } } subs = filtered } ids := make([]int64, 0, len(subs)) for _, sub := range subs { ids = append(ids, sub.ID) } pauses, err := s.db.PausesForSubs(ids) if err != nil { return nil, nil, nil, nil, err } checkins, err := s.db.CheckinsBetween(from, to) if err != nil { return nil, nil, nil, nil, err } urgent, err := s.db.UrgentFlags(from, to) if err != nil { return nil, nil, nil, nil, err } return subs, pauses, checkins, urgent, nil } 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)) pkgID := int64(s.queryInt(r, "pkg", 0)) 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, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID) 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) } back := r.URL.RequestURI() 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: back, PkgID: pkgID, } 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 } data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + 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] isUrgent := urgent[cs.sub.ID][ds] if isUrgent { cell.UrgentCnt++ } 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, Urgent: isUrgent, AssigneeColor: cs.sub.AssigneeColor, } if t.Done || t.Skipped { cell.DoneCount++ } tickets = append(tickets, t) } sort.SliceStable(tickets, func(i, j int) bool { if tickets[i].Urgent != tickets[j].Urgent { return tickets[i].Urgent } if tickets[i].Done != tickets[j].Done { return !tickets[i].Done } return tickets[i].SubID < tickets[j].SubID }) // 竖条按“当天全部任务”算,不能被色签的 3 条上限截断 cell.Strip = assigneeStrip(tickets) if len(tickets) > 3 { cell.More = len(tickets) - 3 tickets = tickets[:3] } cell.Tickets = tickets cell.PendingCount = cell.TaskCount - cell.DoneCount cell.Heat = heatLevel(cell.TaskCount + cell.DueCount) if cell.InMonth { data.MonthDue += cell.DueCount data.MonthTasks += cell.TaskCount data.MonthDone += cell.DoneCount data.MonthUrgent += cell.UrgentCnt } 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 } row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]} 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) } sortTasks(tasks) data.Tasks = tasks for _, row := range tasks { if !row.Done && !row.Skipped { data.TaskPending++ } } 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 } if pkgID > 0 && u.PackageID != pkgID { continue } data.OtherDue = append(data.OtherDue, newSubView(u, today, warn)) } } // 套餐标签 pkgs, _ := s.db.ListPackages(true) separate := []store.Package{} for _, p := range pkgs { if p.CalendarSeparate && !p.Archived { separate = append(separate, p) } } if len(separate) > 0 { data.HasTabs = true data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98", Count: len(list)}) for _, p := range separate { cnt := 0 for _, sub := range subs { if sub.PackageID == p.ID { cnt++ } } data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt}) } } // 快速完成:按尾号列出候选 if tail := r.URL.Query().Get("pick"); tail != "" { data.PickTail = tail for _, row := range tasks { if row.CustomerTail == tail { data.PickRows = append(data.PickRows, row) } } if len(data.PickRows) == 0 { data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务" } } // 追加面板 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 { data.Extend = buildExtendPanel(sub, pkg, today, back, warn) } } } if n, _ := s.db.CustomerCount(); n == 0 { data.NoCustomers = true } if len(pkgs) == 0 { data.NoPackages = true } p := s.newPage(r, "日历", "calendar") p.D = data s.render(w, r, "calendar", p) } // sortTasks 加急优先,其次未完成,最后按结束日。 func sortTasks(tasks []TaskRow) { sort.SliceStable(tasks, func(i, j int) bool { ai, aj := tasks[i], tasks[j] if ai.Urgent != aj.Urgent { return ai.Urgent } adi := ai.Done || ai.Skipped adj := aj.Done || aj.Skipped if adi != adj { return !adi } return ai.EndDate < aj.EndDate }) } // assigneeStrip 把一格里所有任务的打手颜色去重后返回,未指派用灰色占位。 func assigneeStrip(tickets []Ticket) []string { if len(tickets) == 0 { return nil } seen := map[string]bool{} out := make([]string, 0, 4) for _, t := range tickets { c := t.AssigneeColor if c == "" { c = "#8A8F98" } if seen[c] { continue } seen[c] = true out = append(out, c) } return out } 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]) } // stripParam 去掉地址里的某个参数,用于拼“返回”链接。 func stripParam(raw, key string) string { u, err := url.Parse(raw) if err != nil { return raw } q := u.Query() q.Del(key) u.RawQuery = q.Encode() return u.String() }