Files
subtrack/internal/web/handlers_calendar.go
T
chun_qiu bc5076ee41 feat(v1.11.0): 通行证/深渊模式、淡蓝配色与触控小键盘修复
- 触控小键盘补回 3×4 网格样式:上一版重写 CSS 时漏掉了 .keypad/.key,
  按钮退化成行内小方块;现在恢复 46px 圆角键、⌫/✓ 功能键和「触控」开关态。
- 通行证(原「包赛季」):整段有效期在独立日历上按套餐色连续着色(18% 透明度),
  每一格显示同一个「已完成 / 总单数」(0/1 或 1/1);完成后色块保留并显示 1/1。
  沿用 season_done 记录完成日期,交互动线不变。
- 新增深渊模式:套餐设置周期天数 N 与周期数 X;开通时从开始日排出 X 个 N 天周期,
  结束日 = 开始日 + N×X − 1。周期内任意一天完成即算该周期完成;同一周期重复完成
  不重复计数;撤销该周期最后一条完成打卡会回退计数。独立日历连续着色,每格显示
  同一个「已完成周期 / 总周期数」。
- 全量把「包赛季」改名为「通行证」;套餐表单新增通行证 / 深渊选项,深渊显示 N/X 字段;
  客户开通表单、套餐批量开通、订阅列表、客户订阅卡片、日历待办行同步适配,
  通行证 / 深渊不再显示「追加时长」(提交也会被服务端拒绝)。
- 快速完成 / 批量完成对通行证改为整段完成,对深渊走周期打卡,不再错误地按日写打卡。
- 配色改为淡蓝 / 暗蓝:亮色用暗蓝主色 + 淡蓝纸底,暗色用海军蓝底 + 淡蓝强调;
  默认套餐色改为 #3B6EA5;大屏同步蓝调并修正热力图例与今日数字的对比度。
- 大屏页签排除通行证 / 深渊(它们不计每日热力,避免点开是空日历);「今日待打」
  仍会列出待处理的通行证 / 深渊。
- 数据迁移:packages.abyss / packages.cycle_days / subscriptions.abyss /
  subscriptions.cycle_days,老库启动自动补列。
- 修复手机端整段色块把 7 列日历撑出屏幕:日历改成 minmax(0,1fr),小屏色块缩小。
- 版本号 1.10.0 → 1.11.0。

验证:gofmt / go vet / go test 全绿;亮暗两套、1440 与 390 两档截图走查;
通行证 0/1 → 1/1、深渊 1/4 → 2/4、同周期重复完成不重复计数、撤销回退均实测通过。
2026-10-20 02:33:57 +08:00

695 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 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.BlockDone = cs.blockDone()
cell.BlockTotal = cs.blockTotal()
cell.BlockText = fmt.Sprintf("%d/%d", cell.BlockDone, cell.BlockTotal)
cell.BlockTitle = cs.sub.PackageName + " · " + cell.BlockText
}
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
}
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()
}