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

248 lines
7.5 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 web
import (
"fmt"
"net/http"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type subsData struct {
List []SubView
Pager Pager
State string
PackageID int64
Q string
Packages []store.Package
Total int
}
func (s *Server) handleSubscriptions(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
state := r.URL.Query().Get("state")
if state == "" {
state = "active"
}
packageID := int64(s.queryInt(r, "package_id", 0))
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
f := store.SubFilter{Q: q, State: state, PackageID: packageID, Limit: pageSize, Offset: (pageNo - 1) * pageSize, Today: today}
list, total, err := s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
f.Offset = (pager.Page - 1) * pageSize
list, total, err = s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(true)
data := &subsData{
List: subViews(list, today, warn), Pager: pager, State: state,
PackageID: packageID, Q: q, Packages: pkgs, Total: total,
}
p := s.newPage(r, "订阅", "subscriptions")
p.D = data
s.render(w, r, "subscriptions", p)
}
func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
note := strings.TrimSpace(r.PostFormValue("note"))
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
if sub.Season {
s.redirect(w, r, back, "通行证不支持追加时长,请修改套餐或新建订阅")
return
}
if sub.Abyss {
s.redirect(w, r, back, "深渊模式按周期计数,不支持追加时长")
return
}
if sub.Unit == domain.UnitDay {
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
plan, err := s.db.ExtendDays(id, newEnd, note, opID)
if err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
return
}
add := s.formInt(r, "add_count", 0)
optEnd := strings.TrimSpace(r.PostFormValue("new_end_opt"))
if err := s.db.AddCount(id, add, optEnd, note, opID); err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add))
}
func (s *Server) handleSubAction(action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
switch action {
case "pause":
if err := s.db.PauseSub(id, 0); err != nil {
s.redirect(w, r, back, "暂停失败:"+err.Error())
return
}
s.logOp(r, "暂停订阅", "subscription", id, sub.SubTitle()+" 暂停")
s.redirect(w, r, back, "已暂停,剩余时长将冻结")
case "resume":
shift, err := s.db.ResumeSub(id, 0)
if err != nil {
s.redirect(w, r, back, "恢复失败:"+err.Error())
return
}
msg := "已恢复"
if shift > 0 {
msg = fmt.Sprintf("已恢复,结束日顺延 %d 天", shift)
}
s.logOp(r, "恢复订阅", "subscription", id, fmt.Sprintf("%s 恢复,顺延 %d 天", sub.SubTitle(), shift))
s.redirect(w, r, back, msg)
case "close":
if err := s.db.CloseSub(id); err != nil {
s.redirect(w, r, back, "结束失败:"+err.Error())
return
}
s.logOp(r, "结束订阅", "subscription", id, sub.SubTitle()+" 结束")
s.redirect(w, r, back, "订阅已结束")
case "reopen":
if err := s.db.ReopenSub(id); err != nil {
s.redirect(w, r, back, "启用失败:"+err.Error())
return
}
s.logOp(r, "重新启用", "subscription", id, sub.SubTitle()+" 重新启用")
s.redirect(w, r, back, "订阅已重新启用")
default:
http.NotFound(w, r)
}
}
}
func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/calendar?d="+r.PostFormValue("date"))
date := strings.TrimSpace(r.PostFormValue("date"))
status := strings.TrimSpace(r.PostFormValue("status"))
note := strings.TrimSpace(r.PostFormValue("note"))
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
if sub.Season {
if status == "skipped" {
s.redirect(w, r, back, "通行证不能跳过,请直接完成或结束")
return
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, back, "完成通行证失败:"+err.Error())
return
}
s.logOp(r, "通行证完成", "subscription", id, fmt.Sprintf("%s 通行证整段完成(%s)", sub.SubTitle(), date))
s.redirect(w, r, back, "已把 "+sub.CustomerTail+" "+sub.CustomerName+" 的通行证标记为完成")
return
}
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
s.redirect(w, r, back, "打卡失败:"+err.Error())
return
}
label := "已完成"
if status == "skipped" {
label = "已跳过"
}
s.logOp(r, "打卡", "subscription", id, fmt.Sprintf("%s %s 打卡:%s", sub.SubTitle(), date, label))
s.redirect(w, r, back, "已记录:"+sub.CustomerTail+" "+sub.CustomerName+" "+date)
}
func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
back := backURL(r, "/calendar")
subID, err := s.db.DeleteCheckin(id, 0)
if err != nil {
s.redirect(w, r, back, "撤销失败:"+err.Error())
return
}
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
s.redirect(w, r, back, "已撤销打卡")
}
// handleSubDelete 彻底删除订阅。
func (s *Server) handleSubDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
if r.PostFormValue("confirm") != "delete" {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "请在弹窗里确认删除")
return
}
if err := s.db.DeleteSubscription(id); err != nil {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "删除失败:"+err.Error())
return
}
s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」")
}
// handleSeasonDone 从订阅卡片直接完成整段通行证。
func (s *Server) handleSeasonDone(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "完成失败:"+err.Error())
return
}
s.logOp(r, "通行证完成", "subscription", id, sub.SubTitle()+" 整段完成("+date+")")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已把通行证标记为完成")
}