Files
subtrack/internal/web/handlers_packages.go
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

309 lines
8.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 web
import (
"fmt"
"net/http"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type packagesData struct {
List []store.Package
}
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
list, err := s.db.ListPackages(true)
if err != nil {
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
return
}
p := s.newPage(r, "套餐", "packages")
p.D = &packagesData{List: list}
s.render(w, r, "packages", p)
}
type packageFormData struct {
Package *store.Package
IsNew bool
Tags []string
}
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages")
tags, _ := s.db.PackageTags()
p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#3B6EA5"},
IsNew: true,
Tags: tags,
}
s.render(w, r, "package_form", p)
}
func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")),
SortOrder: 0,
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
p.DefaultAmount = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
p.SortOrder = v
}
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
if f, err := strconv.ParseFloat(price, 64); err == nil {
p.PriceCents = int(f*100 + 0.5)
p.HasPrice = true
}
}
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
switch p.Unit {
case "season":
p.Season = true
p.Unit = domain.UnitDay
case "abyss":
p.Abyss = true
p.Unit = domain.UnitDay
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("cycle_days"))); err == nil {
p.CycleDays = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_validity_days"))); err == nil {
p.DefaultValidityDays = v
}
p.SeasonStart = strings.TrimSpace(r.PostFormValue("season_start"))
p.SeasonEnd = strings.TrimSpace(r.PostFormValue("season_end"))
if p.Season && p.DefaultAmount <= 0 {
p.DefaultAmount = 1
}
return p
}
func validatePackage(p *store.Package) string {
if p.Season {
if p.SeasonStart == "" || p.SeasonEnd == "" {
return "通行证套餐必须填写开始日和结束日"
}
if p.SeasonEnd < p.SeasonStart {
return "通行证结束日不能早于开始日"
}
}
if p.Abyss {
if p.CycleDays < 1 || p.CycleDays > 365 {
return "深渊模式的周期天数必须在 1-365 之间"
}
if p.DefaultAmount < 1 || p.DefaultAmount > 1000 {
return "深渊模式的周期数必须在 1-1000 之间"
}
}
if p.Name == "" {
return "套餐名称不能为空"
}
if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字"
}
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0"
}
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色"
}
return ""
}
// packageDetailData 套餐详情页数据。
type packageDetailData struct {
Package *store.Package
Customers []store.Customer
Subscribed map[int64]bool
Q string
Tags []string
Today string
}
// handlePackageDetail 套餐详情:查看信息 + 勾选客户批量开通。
func (s *Server) handlePackageDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
today := domain.Today()
warn := s.warnDays()
q := strings.TrimSpace(r.URL.Query().Get("q"))
list, _, err := s.db.ListCustomers(q, 300, 0, domain.Date(today), warn, false)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
subscribed, err := s.db.SubscribedCustomerIDs(id)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
tags, _ := s.db.PackageTags()
p := s.newPage(r, "套餐详情", "packages")
p.D = &packageDetailData{Package: pkg, Customers: list, Subscribed: subscribed, Q: q, Tags: tags, Today: domain.Date(today)}
s.render(w, r, "package_detail", p)
}
// handlePackageBatchSubscribe 给勾选的客户批量开通该套餐。
func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
back := "/packages/" + itoa(id)
raw := r.PostForm["customer_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何客户")
return
}
start := strings.TrimSpace(r.PostFormValue("start_date"))
if start == "" && !pkg.Season {
start = domain.Date(domain.Today())
}
if start != "" {
if _, err := domain.ParseDate(start); err != nil {
s.redirect(w, r, back, "开始日期格式不正确")
return
}
}
end := strings.TrimSpace(r.PostFormValue("end_date"))
days := s.formInt(r, "days", 0)
count := s.formInt(r, "total_count", 0)
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, skipped, failed := 0, 0, 0
var lastErr string
for _, v := range raw {
cid, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
_, err = s.db.CreateSub(store.CreateSubInput{
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
Days: days, TotalCount: count, OperatorID: op,
})
if err != nil {
if strings.Contains(err.Error(), "已订阅") {
skipped++
continue
}
failed++
lastErr = err.Error()
continue
}
done++
}
s.logOp(r, "批量开通", "package", id,
fmt.Sprintf("%s:成功 %d、跳过 %d、失败 %d", pkg.Name, done, skipped, failed))
msg := fmt.Sprintf("已为 %d 位客户开通「%s」", done, pkg.Name)
if skipped > 0 {
msg += fmt.Sprintf(",%d 位已有该套餐已跳过", skipped)
}
if failed > 0 {
msg += fmt.Sprintf(",%d 位失败(%s)", failed, lastErr)
}
s.redirect(w, r, back, msg)
}
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
pkg := readPackageForm(r)
p := s.newPage(r, "新增套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
id, err := s.db.CreatePackage(pkg)
if err != nil {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
}
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑套餐", "packages")
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.render(w, r, "package_form", p)
}
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg := readPackageForm(r)
pkg.ID = id
p := s.newPage(r, "编辑套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
if err := s.db.UpdatePackage(pkg); err != nil {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已保存")
}
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
archived := !pkg.Archived
if err := s.db.SetPackageArchived(id, archived); err != nil {
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
return
}
action := "归档套餐"
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
if !archived {
action = "取消归档"
msg = "套餐已恢复"
}
s.logOp(r, action, "package", id, action+" "+pkg.Name)
s.redirect(w, r, "/packages", msg)
}