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