Files
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

230 lines
5.6 KiB
Go
Raw Permalink 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 domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
package domain
import "time"
const (
DateLayout = "2006-01-02"
StampLayout = "2006-01-02 15:04:05"
)
// Today 返回本地时区当天零点。
func Today() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// ParseDate 解析 YYYY-MM-DD。
func ParseDate(s string) (time.Time, error) {
return time.ParseInLocation(DateLayout, s, time.Local)
}
// ParseDateOr 解析失败时返回默认值。
func ParseDateOr(s string, fallback time.Time) time.Time {
if t, err := ParseDate(s); err == nil {
return t
}
return fallback
}
// Date 格式化为 YYYY-MM-DD。
func Date(t time.Time) string { return t.Format(DateLayout) }
// DayDiff 返回 b-a 的自然日天数(不受夏令时影响)。
func DayDiff(a, b time.Time) int {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
return int(ub.Sub(ua).Hours() / 24.0)
}
// AddDays 加 n 天。
func AddDays(t time.Time, n int) time.Time { return t.AddDate(0, 0, n) }
// MonthStart 返回当月 1 日。
func MonthStart(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local)
}
// MonthEnd 返回当月最后一天。
func MonthEnd(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local).AddDate(0, 1, -1)
}
// WeekdayIndex 返回以周一为 0 的星期索引。
func WeekdayIndex(t time.Time) int {
wd := int(t.Weekday())
if wd == 0 {
return 6
}
return wd - 1
}
// 订阅状态(存储值)
const (
StatusActive = "active"
StatusPaused = "paused"
StatusClosed = "closed"
)
// 套餐类型
const (
UnitDay = "day"
UnitCount = "count"
)
// CycleIndex 返回 date 在 [start, start+cycleDays*cycles) 内的周期序号(0 起)。
// 越界或参数不合法返回 -1。
func CycleIndex(start, date time.Time, cycleDays int) int {
if cycleDays <= 0 {
return -1
}
d := DayDiff(start, date)
if d < 0 {
return -1
}
return d / cycleDays
}
// CycleStart 返回第 idx 个周期的第一天。
func CycleStart(start time.Time, cycleDays, idx int) time.Time {
return AddDays(start, idx*cycleDays)
}
// CycleEnd 返回第 idx 个周期的最后一天。
func CycleEnd(start time.Time, cycleDays, idx int) time.Time {
return AddDays(start, (idx+1)*cycleDays-1)
}
// 派生展示状态
const (
StateActive = "active" // 进行中
StatePending = "pending" // 未开始
StatePaused = "paused" // 暂停中
StateExpired = "expired" // 已到期
StateUsedUp = "usedup" // 次数用尽
StateClosed = "closed" // 已结束
)
// 到期预警等级
const (
LevelOK = "ok"
LevelWarn = "warn"
LevelToday = "today"
LevelExpired = "expired"
LevelNone = "none" // 无期限(按次且未设有效期)
)
// Eval 是订阅的计算结果。
type Eval struct {
State string
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
RemainingDays int
RemainingCount int
Level string
}
// EvalSub 计算订阅的展示状态与剩余量。
// - unit=day:剩余天数 = end - today + 1(含当天)
// - unit=count:剩余次数 = total - used;end 为可选有效期
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
e := Eval{Level: LevelNone, DaysLeft: -1}
if warnDays <= 0 {
warnDays = 3
}
start := ParseDateOr(startDate, today)
end, hasEnd := time.Time{}, false
if endDate != "" {
if t, err := ParseDate(endDate); err == nil {
end, hasEnd = t, true
e.DaysLeft = DayDiff(today, end)
}
}
if unit == UnitDay {
if hasEnd {
e.RemainingDays = e.DaysLeft + 1
}
if e.RemainingDays < 0 {
e.RemainingDays = 0
}
} else {
e.RemainingCount = total - used
if e.RemainingCount < 0 {
e.RemainingCount = 0
}
}
expired := false
if hasEnd && end.Before(today) {
expired = true
}
if unit == UnitCount && e.RemainingCount <= 0 {
expired = true
}
switch {
case status == StatusClosed:
e.State = StateClosed
case status == StatusPaused:
e.State = StatePaused
case expired:
if unit == UnitCount && e.RemainingCount <= 0 {
e.State = StateUsedUp
} else {
e.State = StateExpired
}
case start.After(today):
e.State = StatePending
default:
e.State = StateActive
}
// 预警等级只对「进行中 / 未开始」有意义
switch e.State {
case StateExpired:
e.Level = LevelExpired
case StateActive, StatePending:
switch {
case !hasEnd:
e.Level = LevelNone
case e.DaysLeft <= 0:
e.Level = LevelToday
case e.DaysLeft <= warnDays:
e.Level = LevelWarn
default:
e.Level = LevelOK
}
}
return e
}
// ExtendPlan 描述一次「按日追加」的换算结果。
type ExtendPlan struct {
Base time.Time // 计算起点
OldEnd time.Time
NewEnd time.Time
Days int
SameDay bool // 新结束日等于原结束日(未增加)
Backwards bool // 新结束日早于计算起点
}
// PlanExtend 根据原结束日、今天与新结束日,算出计算起点与追加天数。
// 规则(PRD R4):已到期从今天起算,未到期从原结束日次日起算。
func PlanExtend(oldEnd, newEnd, today time.Time) ExtendPlan {
p := ExtendPlan{OldEnd: oldEnd, NewEnd: newEnd}
if !oldEnd.Before(today) {
p.Base = AddDays(oldEnd, 1)
} else {
p.Base = today
}
p.Days = DayDiff(p.Base, newEnd) + 1
p.SameDay = DayDiff(oldEnd, newEnd) == 0
p.Backwards = p.Days <= 0
if p.Days < 0 {
p.Days = 0
}
return p
}