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

232 lines
6.5 KiB
Go
Raw 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"
)
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余),
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag)
if err != nil {
return nil, err
}
today := domain.Today()
warn := s.warnDays()
rows := []TaskRow{}
for _, sub := range subs {
if customerIDs != nil && !customerIDs[sub.CustomerID] {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn), checkins: checkins[sub.ID]}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
if cs.isBlock() {
if !cs.blockNeedsWork(date) {
continue
}
row := TaskRow{SubView: newSubView(sub, today, warn), Urgent: urgent[sub.ID][date]}
if ci, ok := checkins[sub.ID][date]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
rows = append(rows, row)
continue
}
if !cs.covers(date) {
continue
}
row := TaskRow{SubView: newSubView(sub, today, warn), Urgent: urgent[sub.ID][date]}
if ci, ok := checkins[sub.ID][date]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
rows = append(rows, row)
}
sortTasks(rows)
return rows, nil
}
// completeTask 完成某个订阅的某一天:通行证走整段完成,其余写打卡。
func (s *Server) completeTask(subID int64, date string, operatorID int64) error {
sub, err := s.db.Sub(subID)
if err != nil {
return err
}
if sub.Season {
return s.db.SeasonComplete(subID, date)
}
return s.db.SaveCheckin(subID, date, "done", "", operatorID)
}
// handleQuickCheckin 输入手机尾号直接完成当日服务:
// 命中 1 项直接完成;命中多项则跳到日历的选择面板。
func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
tail := strings.TrimSpace(r.PostFormValue("tail"))
date := strings.TrimSpace(r.PostFormValue("date"))
pkgID := int64(s.formInt(r, "pkg", 0))
tag := strings.TrimSpace(r.PostFormValue("tag"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
if tail == "" {
s.redirect(w, r, back, "请输入手机尾号")
return
}
customers, err := s.db.CustomersByTail(tail)
if err != nil {
s.redirect(w, r, back, "查询客户失败:"+err.Error())
return
}
if len(customers) == 0 {
s.redirect(w, r, back, "没有找到尾号 "+tail+" 的客户")
return
}
ids := map[int64]bool{}
for _, c := range customers {
ids[c.ID] = true
}
rows, err := s.pendingTasks(date, ids, pkgID, tag)
if err != nil {
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
return
}
pending := make([]TaskRow, 0, len(rows))
for _, row := range rows {
if !row.Done && !row.Skipped {
pending = append(pending, row)
}
}
switch len(pending) {
case 0:
if len(rows) > 0 {
s.redirect(w, r, back, "尾号 "+tail+" 今天的服务已经处理过了")
return
}
s.redirect(w, r, back, "尾号 "+tail+" 今天没有待完成的服务")
return
case 1:
row := pending[0]
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
if err := s.completeTask(row.ID, date, op); err != nil {
s.redirect(w, r, back, "完成失败:"+err.Error())
return
}
s.logOp(r, "快速完成", "subscription", row.ID,
fmt.Sprintf("尾号 %s:%s %s 完成 %s(%s)", tail, row.CustomerTail, row.CustomerName, row.PackageName, date))
s.redirect(w, r, back, "已为 "+row.CustomerTail+" "+row.CustomerName+" 完成「"+row.PackageName+"」")
return
default:
// 多项:回到日历并打开选择面板
target := stripParam(back, "pick")
sep := "?"
if strings.Contains(target, "?") {
sep = "&"
}
s.redirect(w, r, target+sep+"pick="+urlQueryEscape(tail), fmt.Sprintf("尾号 %s 有 %d 项待完成,请勾选", tail, len(pending)))
return
}
}
// handleCheckinBatch 批量完成选中的订阅(同日)。
func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
_ = r.ParseForm()
raw := r.PostForm["sub_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何项")
return
}
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, failed := 0, 0
var lastErr string
for _, v := range raw {
id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
if err := s.completeTask(id, date, op); err != nil {
failed++
lastErr = err.Error()
continue
}
done++
}
if done > 0 {
s.logOp(r, "批量完成", "checkin", 0, fmt.Sprintf("%s 一次完成 %d 项", date, done))
}
msg := fmt.Sprintf("已完成 %d 项", done)
if failed > 0 {
msg += fmt.Sprintf(",%d 项失败(%s)", failed, lastErr)
}
s.redirect(w, r, stripParam(back, "pick"), msg)
}
// handleUrgentToggle 勾选/取消某天某订阅的加急。
func (s *Server) handleUrgentToggle(w http.ResponseWriter, r *http.Request) {
subID := int64(s.formInt(r, "sub_id", 0))
date := strings.TrimSpace(r.PostFormValue("date"))
urgent := r.PostFormValue("urgent") == "1"
back := backURL(r, "/calendar")
if subID <= 0 {
s.redirect(w, r, back, "参数不正确")
return
}
if err := s.db.SetUrgent(subID, date, urgent); err != nil {
s.redirect(w, r, back, "设置加急失败:"+err.Error())
return
}
word := "已取消加急"
if urgent {
word = "已标记加急"
}
if sub, err := s.db.Sub(subID); err == nil {
s.logOp(r, word, "subscription", subID, sub.SubTitle()+" "+date+" "+word)
}
s.redirect(w, r, back, word)
}
func urlQueryEscape(v string) string {
var b strings.Builder
for _, c := range []byte(v) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteString("%")
const hex = "0123456789ABCDEF"
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}