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、同周期重复完成不重复计数、撤销回退均实测通过。
This commit is contained in:
@@ -26,11 +26,12 @@ type DayCell struct {
|
||||
DoneCount int
|
||||
UrgentCnt int
|
||||
Heat int
|
||||
// 赛季色带
|
||||
HasSeason bool
|
||||
SeasonColor string
|
||||
SeasonStart bool
|
||||
SeasonEnd bool
|
||||
// 通行证 / 深渊色块
|
||||
BlockColor string
|
||||
BlockDone int
|
||||
BlockTotal int
|
||||
BlockText string
|
||||
BlockTitle string
|
||||
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
|
||||
PendingCount int
|
||||
Tickets []Ticket
|
||||
@@ -117,12 +118,13 @@ type calendarData struct {
|
||||
|
||||
// calSub 是日历计算用的订阅上下文。
|
||||
type calSub struct {
|
||||
sub store.Subscription
|
||||
start time.Time
|
||||
end time.Time
|
||||
hasEnd bool
|
||||
eval domain.Eval
|
||||
pauses []store.Pause
|
||||
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 {
|
||||
@@ -137,6 +139,71 @@ func (c calSub) pausedOn(date string) bool {
|
||||
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
|
||||
@@ -147,6 +214,12 @@ func (c calSub) covers(date string) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -202,6 +275,21 @@ func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total in
|
||||
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)
|
||||
@@ -237,7 +325,7 @@ func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]s
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
checkins, err := s.db.CheckinsBetween(from, to)
|
||||
checkins, err := s.db.CheckinsBetween(wideFrom(from), wideTo(to))
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
@@ -288,10 +376,11 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
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],
|
||||
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)
|
||||
@@ -349,7 +438,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if cs.sub.EndDate == ds {
|
||||
cell.DueCount++
|
||||
}
|
||||
if !cell.InMonth || !cs.covers(ds) {
|
||||
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++
|
||||
@@ -374,16 +476,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
cell.DoneCount++
|
||||
}
|
||||
tickets = append(tickets, t)
|
||||
if cs.sub.Season && cs.sub.SeasonDone == "" {
|
||||
cell.HasSeason = true
|
||||
cell.SeasonColor = cs.sub.PackageColor
|
||||
if ds == cs.sub.StartDate {
|
||||
cell.SeasonStart = true
|
||||
}
|
||||
if ds == cs.sub.EndDate {
|
||||
cell.SeasonEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(tickets, func(i, j int) bool {
|
||||
if tickets[i].Urgent != tickets[j].Urgent {
|
||||
@@ -421,6 +513,21 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user