package web import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "daili/internal/domain" "daili/internal/store" ) type screenDay struct { Date string Day int InMonth bool IsToday bool IsPast bool Count int Done int Urgent int Heat int // Strip 打手色竖条(去重) Strip []string } type screenTask struct { TaskRow } type screenData struct { Stats *store.Stats MonthTitle string Year, Month int Weeks [][]screenDay Weekdays []string Top []SubView DueToday []SubView TodayList []screenTask Recent []store.Adjustment TodayTasks int TodayDone int TodayUrgent int Refresh int Version string AssetVersion string HeatMode string // task = 每日待完成;due = 到期 Tag string HeatTotal int Tabs []PkgTab PkgID int64 HasTabs bool FilterQuery string Theme string } // screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。 func (s *Server) screenCalSubs(from, to string, pkgID int64, tag string) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID, tag) if err != nil { return nil, nil, nil, err } today := domain.Today() warn := s.warnDays() list := make([]calSub, 0, len(subs)) for _, sub := range subs { cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID], eval: sub.Eval(today, warn)} if sub.EndDate != "" { cs.hasEnd = true cs.end = domain.ParseDateOr(sub.EndDate, today) } list = append(list, cs) } return list, checkins, urgent, nil } func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { today := domain.Today() warn := s.warnDays() pkgID := int64(s.queryInt(r, "pkg", 0)) tag := strings.TrimSpace(r.URL.Query().Get("tag")) heatMode := r.URL.Query().Get("heat") if heatMode != "due" { heatMode = "task" } stats, err := s.db.ScreenStats(today, warn) if err != nil { http.Error(w, "读取大屏数据失败", http.StatusInternalServerError) return } data := &screenData{ Stats: stats, MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())), Year: today.Year(), Month: int(today.Month()), Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, Top: subViews(stats.Top, today, warn), Refresh: s.screenRefresh(), Version: s.cfg.Version, AssetVersion: s.assetVersion, HeatMode: heatMode, PkgID: pkgID, Tag: tag, FilterQuery: "pkg=" + itoa(pkgID), } // 今日到期(可按套餐过滤) dueToday := []store.Subscription{} for _, sub := range stats.DueTodaySubs { if pkgID > 0 && sub.PackageID != pkgID { continue } dueToday = append(dueToday, sub) } data.DueToday = subViews(dueToday, today, warn) // 本月热力:默认统计“每日待完成的单子”,可切换到“到期” monthStart := domain.MonthStart(today.Year(), today.Month()) monthEnd := domain.MonthEnd(today.Year(), today.Month()) gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart)) weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7 gridEnd := domain.AddDays(gridStart, weeks*7-1) list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag) if err != nil { http.Error(w, "读取大屏日历失败", http.StatusInternalServerError) return } for wi := 0; wi < weeks; wi++ { week := make([]screenDay, 0, 7) for di := 0; di < 7; di++ { d := domain.AddDays(gridStart, wi*7+di) ds := domain.Date(d) count, doneN, urgentN := 0, 0, 0 strip := []string{} seenColor := map[string]bool{} for _, cs := range list { if heatMode == "due" { if cs.sub.EndDate == ds { count++ } continue } // 通行证 / 深渊是整段型,不计入「每日待完成」热力 if cs.isBlock() { continue } if !cs.covers(ds) { continue } ci, has := checkins[cs.sub.ID][ds] if has && (ci.Status == "done" || ci.Status == "skipped") { doneN++ continue // “待完成”热力只统计还没处理的 } count++ if urgent[cs.sub.ID][ds] { urgentN++ } color := cs.sub.AssigneeColor if color == "" { color = "#8A8F98" } if !seenColor[color] { seenColor[color] = true strip = append(strip, color) } } if d.Month() == today.Month() { data.HeatTotal += count } week = append(week, screenDay{ Date: ds, Day: d.Day(), InMonth: d.Month() == today.Month(), IsToday: ds == domain.Date(today), IsPast: d.Before(today), Count: count + doneN, Done: doneN, Urgent: urgentN, Heat: heatLevel(count), Strip: strip, }) } data.Weeks = append(data.Weeks, week) } // 今日待打(加急置顶) rows, err := s.pendingTasks(domain.Date(today), nil, pkgID, tag) if err == nil { for _, row := range rows { if row.Done || row.Skipped { continue } data.TodayList = append(data.TodayList, screenTask{TaskRow: row}) if row.Urgent { data.TodayUrgent++ } } } data.TodayTasks = len(data.TodayList) for _, row := range rows { if row.Done { data.TodayDone++ } } // 最近追加(底部细条) if adj, err := s.db.RecentAdjustments(6); err == nil { data.Recent = adj } // 页签:按标签合并 + 独立显示另开;通行证/深渊不计每日热力,不放大屏页签 pkgs, _ := s.db.ListPackages(true) screenPkgs := make([]store.Package, 0, len(pkgs)) for _, p := range pkgs { if p.Season || p.Abyss { continue } screenPkgs = append(screenPkgs, p) } data.Tabs, data.HasTabs = buildCalendarTabs(screenPkgs, nil, 0) for i := range data.Tabs { t := &data.Tabs[i] href := "/screen?heat=" + heatMode 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 tag != "" { data.FilterQuery = "tag=" + url.QueryEscape(tag) } data.Theme = themeFromRequest(r) p := s.newPage(r, "大屏", "screen") p.Theme = data.Theme p.D = data s.render(w, r, "screen", p) } // handleScreenDay 返回某天的待办明细,供大屏悬停浮层使用。 func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) { date := r.URL.Query().Get("date") if _, err := domain.ParseDate(date); err != nil { http.Error(w, "日期不正确", http.StatusBadRequest) return } pkgID := int64(s.queryInt(r, "pkg", 0)) rows, err := s.pendingTasks(date, nil, pkgID, strings.TrimSpace(r.URL.Query().Get("tag"))) if err != nil { http.Error(w, "读取失败", http.StatusInternalServerError) return } type item struct { Tail string `json:"tail"` Name string `json:"name"` Pkg string `json:"pkg"` Color string `json:"color"` Remain string `json:"remain"` Urgent bool `json:"urgent"` Done bool `json:"done"` Who string `json:"who"` WhoCol string `json:"who_color"` } out := struct { Date string `json:"date"` Total int `json:"total"` Done int `json:"done"` Urgent int `json:"urgent"` More int `json:"more"` Items []item `json:"items"` }{Date: date} const limit = 60 for _, row := range rows { out.Total++ if row.Done || row.Skipped { out.Done++ } if row.Urgent { out.Urgent++ } if len(out.Items) >= limit { out.More++ continue } out.Items = append(out.Items, item{ Tail: row.CustomerTail, Name: row.CustomerName, Pkg: row.PackageName, Color: row.PackageColor, Remain: row.RemainText, Urgent: row.Urgent, Done: row.Done || row.Skipped, Who: row.AssigneeLabel(), WhoCol: row.AssigneeHex(), }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(out) }