package web import ( "fmt" "net/http" "net/url" "sort" "strconv" "time" "daili/internal/domain" "daili/internal/store" ) // SubView 是订阅的展示视图(已算好状态、文案与进度)。 type SubView struct { store.Subscription Eval domain.Eval StateLabel string StateClass string RemainText string RemainShort string PeriodText string DueText string Progress int Checkin *store.Checkin CheckinState string UnitLabel string Today string // CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内) CanCheckinToday bool } // AssigneeLabel 返回打手显示名;未指派时给灰字提示。 func (v SubView) AssigneeLabel() string { if v.AssigneeName == "" { return "未指派" } return v.AssigneeName } // AssigneeHex 返回打手颜色;未指派用中性灰。 func (v SubView) AssigneeHex() string { if v.AssigneeColor == "" { return "#8A8F98" } return v.AssigneeColor } // AssigneeNone 是否未指派。 func (v SubView) AssigneeNone() bool { return v.AssigneeName == "" } func stateLabel(state string) (string, string) { switch state { case domain.StateActive: return "进行中", "active" case domain.StatePending: return "未开始", "pending" case domain.StatePaused: return "暂停中", "paused" case domain.StateExpired: return "已到期", "expired" case domain.StateUsedUp: return "次数用尽", "usedup" case domain.StateClosed: return "已结束", "closed" } return "未知", "unknown" } func newSubView(s store.Subscription, today time.Time, warnDays int) SubView { e := s.Eval(today, warnDays) label, class := stateLabel(e.State) v := SubView{ Subscription: s, Eval: e, StateLabel: label, StateClass: class, Today: domain.Date(today), } if s.Season { v.UnitLabel = "赛季" if s.SeasonDone != "" { v.RemainText = "赛季已完成" v.RemainShort = "已完成" v.StateLabel = "赛季已完成" v.StateClass = "active" } else { v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays) v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays) } v.PeriodText = s.StartDate + " → " + s.EndDate return v } if s.Unit == domain.UnitDay { v.UnitLabel = "按日" v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays) v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays) v.PeriodText = s.StartDate + " → " + s.EndDate total := domain.DayDiff(domain.ParseDateOr(s.StartDate, today), domain.ParseDateOr(s.EndDate, today)) + 1 if total > 0 { v.Progress = clampPct(e.RemainingDays * 100 / total) } } else { v.UnitLabel = "按次" v.RemainText = fmt.Sprintf("已完成 %d / %d 次", s.UsedCount, s.TotalCount) v.RemainShort = fmt.Sprintf("%d/%d 次", s.UsedCount, s.TotalCount) v.PeriodText = s.StartDate + " 起" if s.EndDate != "" { v.PeriodText += " · 有效期至 " + s.EndDate } else { v.PeriodText += " · 无有效期" } if s.TotalCount > 0 { v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount) v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount) } } switch e.Level { case domain.LevelToday: v.DueText = "今天到期" case domain.LevelWarn: switch { case e.DaysLeft == 1: v.DueText = "明天到期" case e.DaysLeft > 1: v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) case s.Unit == domain.UnitDay: v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays) default: v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) } case domain.LevelExpired: if s.EndDate != "" { d := domain.DayDiff(domain.ParseDateOr(s.EndDate, today), today) if d > 0 { v.DueText = fmt.Sprintf("已过期 %d 天", d) } else { v.DueText = "已到期" } } else { v.DueText = "次数已用尽" } } // 今天能否打卡:进行中且(按日或有剩余次数) v.CanCheckinToday = e.State == domain.StateActive && (s.Unit == domain.UnitDay || e.RemainingCount > 0) return v } func clampPct(v int) int { if v < 0 { return 0 } if v > 100 { return 100 } return v } func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubView { out := make([]SubView, 0, len(subs)) for _, s := range subs { out = append(out, newSubView(s, today, warnDays)) } return out } // ---------- 追加面板 ---------- // ExtendPanel 是「追加时长/次数」面板数据。 type ExtendPanel struct { Sub store.Subscription View SubView IsDay bool Today string OldEnd string Base string Suggested string SuggestedDays int Back string Months []MiniMonth DefaultAmount int } // MiniMonth 是面板内的迷你月历。 type MiniMonth struct { Title string Weeks [][]MiniDay } // MiniDay 是迷你月历的一天。 type MiniDay struct { Date string Day int InMonth bool IsOldEnd bool IsToday bool Disabled bool IsWeekend bool } func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time, back string, warnDays int) *ExtendPanel { p := &ExtendPanel{ Sub: *s, View: newSubView(*s, today, warnDays), IsDay: s.Unit == domain.UnitDay, Today: domain.Date(today), Back: back, DefaultAmount: pkg.DefaultAmount, } if p.DefaultAmount <= 0 { p.DefaultAmount = 30 } if s.Unit != domain.UnitDay { return p } oldEnd := domain.ParseDateOr(s.EndDate, today) plan := domain.PlanExtend(oldEnd, oldEnd, today) suggested := domain.AddDays(plan.Base, p.DefaultAmount-1) p.OldEnd = domain.Date(oldEnd) p.Base = domain.Date(plan.Base) p.Suggested = domain.Date(suggested) p.SuggestedDays = p.DefaultAmount anchor := oldEnd if anchor.Before(today) { anchor = today } sy, sm := anchor.Year(), anchor.Month() for i := 0; i < 3; i++ { mm := time.Month(int(sm) + i) y := sy for mm > 12 { mm -= 12 y++ } p.Months = append(p.Months, buildMiniMonth(y, mm, plan.Base, oldEnd, today)) } return p } func buildMiniMonth(y int, m time.Month, base, oldEnd, today time.Time) MiniMonth { first := domain.MonthStart(y, m) last := domain.MonthEnd(y, m) start := domain.AddDays(first, -domain.WeekdayIndex(first)) end := domain.AddDays(last, 7-domain.WeekdayIndex(last)-1) if domain.WeekdayIndex(last) == 6 { end = last } mm := MiniMonth{Title: fmt.Sprintf("%d 年 %d 月", y, int(m))} var week []MiniDay for d := start; !d.After(end); d = domain.AddDays(d, 1) { md := MiniDay{ Date: domain.Date(d), Day: d.Day(), InMonth: d.Month() == m, IsOldEnd: domain.Date(d) == domain.Date(oldEnd), IsToday: domain.Date(d) == domain.Date(today), Disabled: d.Before(base), IsWeekend: d.Weekday() == time.Saturday || d.Weekday() == time.Sunday, } week = append(week, md) if len(week) == 7 { mm.Weeks = append(mm.Weeks, week) week = nil } } if len(week) > 0 { mm.Weeks = append(mm.Weeks, week) } return mm } // ---------- 时间轴 ---------- // Timeline 是客户订阅的时间轴(签带)。 type Timeline struct { Start string End string Ticks []Tick Bars []Bar } // Tick 是时间轴刻度。 type Tick struct { Label string Left float64 } // Bar 是一条订阅签带。 type Bar struct { SubID int64 Name string Color string Left float64 Width float64 DueLeft float64 Label string State string Pauses []BarPause HasDue bool } // BarPause 是签带上的暂停灰段。 type BarPause struct { Left float64 Width float64 } func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, today time.Time) *Timeline { if len(subs) == 0 { return nil } start := today end := today for _, s := range subs { st := domain.ParseDateOr(s.StartDate, today) if st.Before(start) { start = st } en := st.AddDate(0, 0, 30) if s.EndDate != "" { en = domain.ParseDateOr(s.EndDate, en) } if en.After(end) { end = en } } start = domain.AddDays(start, -7) end = domain.AddDays(end, 14) totalDays := domain.DayDiff(start, end) + 1 if totalDays <= 0 { return nil } pos := func(t time.Time) float64 { return float64(domain.DayDiff(start, t)) / float64(totalDays) * 100 } tl := &Timeline{Start: domain.Date(start), End: domain.Date(end)} // 月刻度 cur := domain.MonthStart(start.Year(), start.Month()) if cur.Before(start) { cur = cur.AddDate(0, 1, 0) } for !cur.After(end) { tl.Ticks = append(tl.Ticks, Tick{ Label: fmt.Sprintf("%d.%02d", cur.Year(), int(cur.Month())), Left: pos(cur), }) cur = cur.AddDate(0, 1, 0) } for _, s := range subs { st := domain.ParseDateOr(s.StartDate, today) en := st.AddDate(0, 0, 30) if s.EndDate != "" { en = domain.ParseDateOr(s.EndDate, en) } if en.Before(st) { en = st } b := Bar{ SubID: s.ID, Name: s.PackageName, Color: s.PackageColor, Left: pos(st), Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100, Label: domain.Date(st) + " → " + domain.Date(en), State: s.Status, HasDue: s.EndDate != "", } b.DueLeft = pos(en) if b.Width < 0.8 { b.Width = 0.8 } for _, p := range pauses[s.ID] { ps := domain.ParseDateOr(p.StartDate, st) var pe time.Time if p.EndDate != "" { pe = domain.ParseDateOr(p.EndDate, today) } else { pe = today } if pe.Before(ps) { pe = ps } if ps.Before(st) { ps = st } if pe.After(en) { pe = en } if pe.Before(ps) { continue } b.Pauses = append(b.Pauses, BarPause{ Left: pos(ps), Width: float64(domain.DayDiff(ps, pe)+1) / float64(totalDays) * 100, }) } tl.Bars = append(tl.Bars, b) } sort.Slice(tl.Bars, func(i, j int) bool { return tl.Bars[i].SubID < tl.Bars[j].SubID }) return tl } // ---------- 分页 ---------- // Pager 是分页控件数据。 type Pager struct { Page int Pages int Total int Prev string Next string From int To int } func newPager(r *http.Request, pageNo, perPage, total int) Pager { pages := (total + perPage - 1) / perPage if pages < 1 { pages = 1 } if pageNo < 1 { pageNo = 1 } if pageNo > pages { pageNo = pages } mk := func(n int) string { q := url.Values{} for k, v := range r.URL.Query() { q[k] = v } q.Set("page", strconv.Itoa(n)) return r.URL.Path + "?" + q.Encode() } p := Pager{Page: pageNo, Pages: pages, Total: total} p.From = (pageNo-1)*perPage + 1 if total == 0 { p.From = 0 } p.To = pageNo * perPage if p.To > total { p.To = total } if pageNo > 1 { p.Prev = mk(pageNo - 1) } if pageNo < pages { p.Next = mk(pageNo + 1) } return p }