package web import ( "fmt" "net/http" "net/url" "sort" "strings" "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 // 通行证 / 深渊色块 BlockColor string BlockDone int BlockTotal int BlockText string BlockTitle string // BlockPercent 色块底色浓度:通行证固定 18%,深渊按周期依次减淡 BlockPercent 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 Tag string Name string Color string Count int Href string Active bool } 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 Tag string // FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用 FilterQuery string 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 checkins map[string]store.Checkin } 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 } // isBlock 是否为通行证 / 深渊这种整段型订阅。 func (c calSub) isBlock() bool { return c.sub.IsBlock() } // blockCovers 只判断日期是否落在整段区间内(已完成也继续显示色块)。 func (c calSub) blockCovers(date string) bool { if c.sub.Status == domain.StatusClosed || !c.hasEnd { return false } return date >= c.sub.StartDate && date <= c.sub.EndDate } // blockDone 返回整段型订阅的已完成数。 func (c calSub) blockDone() int { return c.sub.BlockDone() } // blockTotal 返回整段型订阅的总数。 func (c calSub) blockTotal() int { return c.sub.BlockTotal() } // cycleBounds 返回 date 所在深渊周期的起止日。 func (c calSub) cycleBounds(date string) (string, string, bool) { if !c.sub.Abyss || c.sub.CycleDays <= 0 { return "", "", false } d, err := domain.ParseDate(date) if err != nil { return "", "", false } idx := domain.CycleIndex(c.start, d, c.sub.CycleDays) if idx < 0 || idx >= c.sub.TotalCount { return "", "", false } return domain.Date(domain.CycleStart(c.start, c.sub.CycleDays, idx)), domain.Date(domain.CycleEnd(c.start, c.sub.CycleDays, idx)), true } // cycleDone 判断 date 所在周期是否已经完成(周期内任意一天打卡即算)。 func (c calSub) cycleDone(date string) bool { s, e, ok := c.cycleBounds(date) if !ok { return false } for d, ci := range c.checkins { if d >= s && d <= e && ci.Status == "done" { return true } } return false } // blockNeedsWork 表示这一天是否需要出现在待完成清单里。 func (c calSub) blockNeedsWork(date string) bool { if !c.blockCovers(date) || c.sub.Status == domain.StatusClosed { return false } if c.sub.Season { return c.sub.SeasonDone == "" } if c.sub.Abyss { if c.sub.TotalCount <= 0 || c.sub.UsedCount >= c.sub.TotalCount { return false } return !c.cycleDone(date) } return false } func (c calSub) covers(date string) bool { if c.sub.Status == domain.StatusClosed || !c.hasEnd { return false } if c.sub.Season { if c.sub.SeasonDone != "" { return false } return date >= c.sub.StartDate && date <= c.sub.EndDate } if c.sub.Abyss { if c.sub.TotalCount > 0 && c.sub.UsedCount >= c.sub.TotalCount { return false } return date >= c.sub.StartDate && date <= c.sub.EndDate && !c.pausedOn(date) } 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) } // buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。 func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total int) ([]PkgTab, bool) { tagOrder := []string{} tagColor := map[string]string{} tagCount := map[string]int{} pkgTag := map[int64]string{} sep := []store.Package{} for _, p := range pkgs { if p.Archived { continue } pkgTag[p.ID] = p.Tag if p.Tag != "" { if _, ok := tagColor[p.Tag]; !ok { tagOrder = append(tagOrder, p.Tag) tagColor[p.Tag] = p.Color } } if p.CalendarSeparate { sep = append(sep, p) } } for _, sub := range subs { if t := pkgTag[sub.PackageID]; t != "" { tagCount[t]++ } } if len(tagOrder) == 0 && len(sep) == 0 { return nil, false } tabs := []PkgTab{{ID: 0, Name: "全部", Color: "#8A8F98", Count: total}} for _, t := range tagOrder { tabs = append(tabs, PkgTab{Tag: t, Name: t, Color: tagColor[t], Count: tagCount[t]}) } for _, p := range sep { cnt := 0 for _, sub := range subs { if sub.PackageID == p.ID { cnt++ } } tabs = append(tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt}) } return tabs, true } // wideFrom/wideTo 把打卡查询范围放大,保证跨月的深渊周期也能判定完成。 func wideFrom(s string) string { if t, err := domain.ParseDate(s); err == nil { return domain.Date(domain.AddDays(t, -400)) } return s } func wideTo(s string) string { if t, err := domain.ParseDate(s); err == nil { return domain.Date(domain.AddDays(t, 400)) } return s } // loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。 func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]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 || tag != "" { pkgs, err := s.db.ListPackages(true) if err != nil { return nil, nil, nil, nil, err } tags := make(map[int64]string, len(pkgs)) for _, p := range pkgs { tags[p.ID] = p.Tag } filtered := make([]store.Subscription, 0, len(subs)) for _, sub := range subs { if pkgID > 0 && sub.PackageID != pkgID { continue } if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag { continue } 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(wideFrom(from), wideTo(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)) tag := strings.TrimSpace(r.URL.Query().Get("tag")) 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, tag) 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], checkins: checkins[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, Tag: tag, FilterQuery: "pkg=" + itoa(pkgID), } if tag != "" { data.FilterQuery = "tag=" + url.QueryEscape(tag) } 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 { continue } if cs.isBlock() { if cs.blockCovers(ds) && cell.BlockText == "" { cell.BlockColor = cs.sub.PackageColor cell.BlockText = blockCellText(cs, ds) cell.BlockDone = 0 if cell.BlockText == "1/1" { cell.BlockDone = 1 } cell.BlockTotal = 1 cell.BlockTitle = cs.sub.PackageName + " · " + cell.BlockText cell.BlockPercent = blockCellPercent(cs, day) } continue } if !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.isBlock() { if !cs.blockNeedsWork(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) continue } 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 } if pkgID <= 0 && tag != "" { if p, err := s.db.Package(u.PackageID); err != nil || p.Tag != tag { continue } } data.OtherDue = append(data.OtherDue, newSubView(u, today, warn)) } } // 页签:先按「套餐标签」合并,再给勾了独立显示的套餐各开一个 pkgs, _ := s.db.ListPackages(true) data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list)) for i := range data.Tabs { t := &data.Tabs[i] href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel) if t.Tag != "" { href += "&tag=" + url.QueryEscape(t.Tag) t.Active = t.Tag == tag } else if t.ID > 0 { href += "&pkg=" + itoa(t.ID) t.Active = t.ID == pkgID } else { t.Active = tag == "" && pkgID == 0 } t.Href = href } // 快速完成:按尾号列出候选 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 } // blockCellText 整段型订阅在 date 所在周期的完成文本: // 通行证 0/1 或 1/1;深渊按 date 所在的那一个周期 0/1 或 1/1,不显示全局 x/N。 func blockCellText(cs calSub, date string) string { if cs.sub.Abyss { if cs.cycleDone(date) { return "1/1" } return "0/1" } if cs.sub.SeasonDone != "" { return "1/1" } return "0/1" } // blockCellPercent 整段型色块浓度:通行证 18%;深渊按所在周期 34%→4%。 func blockCellPercent(cs calSub, day time.Time) int { if cs.sub.Abyss && cs.sub.CycleDays > 0 { idx := domain.CycleIndex(cs.start, day, cs.sub.CycleDays) return abyssBlockPercent(idx, cs.sub.TotalCount) } return 18 } // abyssBlockPercent 深渊色块浓度:第一个周期 34%,之后每个周期依次减淡, // 最后一个周期 4%(周期数大于 1 时线性过渡);跨度比 v1.11.3 的 22%→10% 明显更大。 func abyssBlockPercent(idx, total int) int { if total <= 1 { return 34 } if idx < 0 { idx = 0 } if idx >= total { idx = total - 1 } pct := 34 - (idx*30)/(total-1) if pct < 4 { pct = 4 } return pct } 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() }