Files
subtrack/internal/web/handlers_calendar.go
T
subtrack 1f7d0f0cc4
build / build-and-test (push) Successful in 3m19s
feat(v1.5): 触摸键盘、格子大字计数、客户停用/删除、按钮收纳、7 天徽标、抽屉时钟
1) 工作台速录
- 手机尾号输入框下方新增 3×4 触摸小键盘(1-9、0、⌫、✓),点 ✓ 直接提交;实体键盘仍可输入
- 抽屉顶部新增秒级时钟(HH:MM:SS),位于日期标题上方

2) 日历格子
- 移除排单缩略信息(客户名/套餐色签/“x/y 单”小字)
- 保留左侧打手颜色竖条,中间改为大字「待完成 / 总数」,例如 1/2 = 还剩 1 单、共 2 单
- 大屏格子按此前约定保持原样

3) 客户停用 / 恢复 / 彻底删除
- customers 新增 archived 列(0/1);停用后默认从客户列表、日历、待办、大屏、到期提醒、徽标中隐藏,订阅与打卡记录原样保留
- 恢复后立即回到日历;列表右上角可切换「显示已停用」,已停用行带标记
- 彻底删除为二次确认操作,依赖外键级联清理订阅、打卡、暂停、追加记录与加急标记
- 停用/恢复/删除都会写入操作日志

4) 按钮收纳
- 任务行只露出「完成」,跳过 / 加急(取消加急)/ 追加收进「⋯」菜单;已完成行只露「撤销打卡」等,点空白处自动收起
- 加急、跳过等原有能力不变,仅入口折叠

5) 顶栏
- 在「今日到期」「3 天内到期」右侧新增「7 天内到期」徽标,固定统计未来 7 天

6) 视觉
- 移除管理端背景的横线装饰,改为纯底色;大屏网格背景保留

数据库兼容性:
- 只新增 customers.archived 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变

测试:
- 新增单测 TestCustomerArchiveFilters:停用后日历/到期/徽标/搜索/列表全部过滤、恢复回日历、彻底删除级联清理订阅与打卡
- 功能验证 20 项(键盘、格子计数、折叠菜单、7 天徽标、时钟、停用/恢复/删除、背景)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.5.0
2026-10-19 02:12:25 +08:00

495 lines
12 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"
"net/url"
"sort"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// DayCell 是日历格。
type DayCell struct {
Date string
Day int
InMonth bool
IsToday bool
IsWeekend bool
IsSelected bool
IsPast bool
DueCount int
TaskCount int
DoneCount int
UrgentCnt int
Heat int
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
PendingCount int
Tickets []Ticket
More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
}
// Ticket 是日历格里的色签。
type Ticket struct {
SubID int64
Tail string
Name string
Pkg string
Color string
Done bool
Skipped bool
IsDue bool
Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
}
// TaskRow 是当日待打清单的一行。
type TaskRow struct {
SubView
CheckinID int64
Done bool
Skipped bool
Note string
Operator string
Urgent bool
}
// PkgTab 是日历上方的套餐标签。
type PkgTab struct {
ID int64
Name string
Color string
Count int
}
type calendarData struct {
Year, Month int
Title string
PrevY, PrevM int
NextY, NextM int
TodayStr string
Weekdays []string
Weeks [][]DayCell
Selected string
SelectedLabel string
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
TaskPending int
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
MonthUrgent int
NoPackages bool
NoCustomers bool
Back string
// 套餐标签(在日历中独立显示的套餐)
Tabs []PkgTab
PkgID int64
HasTabs bool
// 快速完成:按尾号筛选出的候选
PickTail string
PickRows []TaskRow
PickMsg string
}
// calSub 是日历计算用的订阅上下文。
type calSub struct {
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
eval domain.Eval
pauses []store.Pause
}
func (c calSub) pausedOn(date string) bool {
for _, p := range c.pauses {
if p.StartDate == "" {
continue
}
if date >= p.StartDate && (p.EndDate == "" || date < p.EndDate) {
return true
}
}
return false
}
func (c calSub) covers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false
}
if date < c.sub.StartDate || date > c.sub.EndDate {
return false
}
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
return false
}
return !c.pausedOn(date)
}
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 时只保留该套餐。
func (s *Server) loadCalendarSubs(from, to string, pkgID int64) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, err := s.db.CalendarSubs(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
if pkgID > 0 {
filtered := make([]store.Subscription, 0, len(subs))
for _, sub := range subs {
if sub.PackageID == pkgID {
filtered = append(filtered, sub)
}
}
subs = filtered
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
return nil, nil, nil, nil, err
}
checkins, err := s.db.CheckinsBetween(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
urgent, err := s.db.UrgentFlags(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
return subs, pauses, checkins, urgent, nil
}
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
y := s.queryInt(r, "y", today.Year())
m := s.queryInt(r, "m", int(today.Month()))
if m < 1 || m > 12 {
y, m = today.Year(), int(today.Month())
}
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
pkgID := int64(s.queryInt(r, "pkg", 0))
sel := r.URL.Query().Get("d")
selT, err := domain.ParseDate(sel)
if sel != "" && err != nil {
sel = ""
}
if sel == "" {
if today.After(monthEnd) || today.Before(monthStart) {
sel = domain.Date(monthStart)
} else {
sel = domain.Date(today)
}
selT = domain.ParseDateOr(sel, today)
}
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
}
if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today)
cs.hasEnd = true
}
list = append(list, cs)
}
back := r.URL.RequestURI()
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
TodayStr: domain.Date(today),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: back,
PkgID: pkgID,
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
data.PrevM = 12
data.PrevY = y - 1
}
data.NextY, data.NextM = y, m+1
if data.NextM > 12 {
data.NextM = 1
data.NextY = y + 1
}
data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
for wi := 0; wi < weeks; wi++ {
week := make([]DayCell, 0, 7)
for di := 0; di < 7; di++ {
day := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(day)
cell := DayCell{
Date: ds,
Day: day.Day(),
InMonth: day.Month() == time.Month(m),
IsToday: ds == domain.Date(today),
IsWeekend: di >= 5,
IsSelected: ds == sel,
IsPast: day.Before(today),
}
tickets := []Ticket{}
for _, cs := range list {
if cs.sub.EndDate == ds {
cell.DueCount++
}
if !cell.InMonth || !cs.covers(ds) {
continue
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
isUrgent := urgent[cs.sub.ID][ds]
if isUrgent {
cell.UrgentCnt++
}
t := Ticket{
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor,
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
}
if t.Done || t.Skipped {
cell.DoneCount++
}
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Urgent != tickets[j].Urgent {
return tickets[i].Urgent
}
if tickets[i].Done != tickets[j].Done {
return !tickets[i].Done
}
return tickets[i].SubID < tickets[j].SubID
})
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 {
cell.More = len(tickets) - 3
tickets = tickets[:3]
}
cell.Tickets = tickets
cell.PendingCount = cell.TaskCount - cell.DoneCount
cell.Heat = heatLevel(cell.TaskCount + cell.DueCount)
if cell.InMonth {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
data.MonthUrgent += cell.UrgentCnt
}
week = append(week, cell)
}
data.Weeks = append(data.Weeks, week)
}
// 当日清单
due := []SubView{}
tasks := []TaskRow{}
for _, cs := range list {
if cs.sub.EndDate == sel {
due = append(due, newSubView(cs.sub, today, warn))
}
if !cs.covers(sel) {
continue
}
row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]}
if ci, ok := checkins[cs.sub.ID][sel]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
tasks = append(tasks, row)
}
sortTasks(tasks)
data.Tasks = tasks
for _, row := range tasks {
if !row.Done && !row.Skipped {
data.TaskPending++
}
}
data.Due = due
// 其它日期到期(不在当天的 7 天内)
if upcoming, err := s.db.RecentDue(domain.Date(today), 7); err == nil {
for _, u := range upcoming {
if u.EndDate == sel {
continue
}
if pkgID > 0 && u.PackageID != pkgID {
continue
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 套餐标签
pkgs, _ := s.db.ListPackages(true)
separate := []store.Package{}
for _, p := range pkgs {
if p.CalendarSeparate && !p.Archived {
separate = append(separate, p)
}
}
if len(separate) > 0 {
data.HasTabs = true
data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98", Count: len(list)})
for _, p := range separate {
cnt := 0
for _, sub := range subs {
if sub.PackageID == p.ID {
cnt++
}
}
data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
}
}
// 快速完成:按尾号列出候选
if tail := r.URL.Query().Get("pick"); tail != "" {
data.PickTail = tail
for _, row := range tasks {
if row.CustomerTail == tail {
data.PickRows = append(data.PickRows, row)
}
}
if len(data.PickRows) == 0 {
data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务"
}
}
// 追加面板
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, err := s.db.Sub(int64(eid)); err == nil {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if len(pkgs) == 0 {
data.NoPackages = true
}
p := s.newPage(r, "日历", "calendar")
p.D = data
s.render(w, r, "calendar", p)
}
// sortTasks 加急优先,其次未完成,最后按结束日。
func sortTasks(tasks []TaskRow) {
sort.SliceStable(tasks, func(i, j int) bool {
ai, aj := tasks[i], tasks[j]
if ai.Urgent != aj.Urgent {
return ai.Urgent
}
adi := ai.Done || ai.Skipped
adj := aj.Done || aj.Skipped
if adi != adj {
return !adi
}
return ai.EndDate < aj.EndDate
})
}
// assigneeStrip 把一格里所有任务的打手颜色去重后返回,未指派用灰色占位。
func assigneeStrip(tickets []Ticket) []string {
if len(tickets) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, 4)
for _, t := range tickets {
c := t.AssigneeColor
if c == "" {
c = "#8A8F98"
}
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
func heatLevel(n int) int {
switch {
case n <= 0:
return 0
case n <= 2:
return 1
case n <= 5:
return 2
case n <= 9:
return 3
default:
return 4
}
}
// shortName 取套餐名前两个字符,用作日历色签上的短标签。
func shortName(s string) string {
r := []rune(s)
if len(r) <= 2 {
return s
}
return string(r[:2])
}
// stripParam 去掉地址里的某个参数,用于拼“返回”链接。
func stripParam(raw, key string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
q := u.Query()
q.Del(key)
u.RawQuery = q.Encode()
return u.String()
}