- 触控小键盘补回 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、同周期重复完成不重复计数、撤销回退均实测通过。
230 lines
5.6 KiB
Go
230 lines
5.6 KiB
Go
// 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
|
||
}
|