feat(v1.2): 亮暗主题、按尾号快速完成、加急置顶、按套餐分日历、大屏升级

1) 全站亮色 / 暗色
- 管理端与大屏都提供切换按钮(顶栏 ☾/☀、大屏右上角),偏好写 cookie,服务端首屏直出,不闪
- 未选择时跟随系统(prefers-color-scheme);两套配色都改成 CSS 变量,色签、热力、表格、抽屉全部适配
- 大屏默认仍是深色记分板,浅色为纸感背景

2) 工作台 → 当日待完成:按手机尾号快速完成
- 输入尾号回车:唯一匹配直接完成并提示
- 同尾号多客户 / 一单多服务:跳到选择面板,默认全选,可多选后一次完成
- 新增 POST /checkins/quick 与 POST /checkins/batch;抽屉标题改为「未完成数 / 总单数」

3) 加急
- 每条任务右侧可勾选/取消加急;加急任务红 tag + 置顶(抽屉与大屏「今日待打」)
- 日历格子出现红点,单据色签加红边;大屏热力格也带红点
- 新增表 task_flags(sub_id, service_date, urgent),按 订阅+日期 存,避免和打卡状态耦合

4) 套餐「在日历中独立显示」
- 套餐表单新增勾选项,列表页显示「独立日历」标记
- 勾选后工作台与大屏顶部出现套餐标签页(全部 / 每日任务 / …),可只看某类委托的排单、热量与待办
- 新增列 packages.calendar_separate;老库打开时自动 ALTER,无需手工迁移

5) 大屏
- 热力默认改为「每日待完成单量」,右上角可切回「到期热力」
- 鼠标悬停任意日期格,浮出当日待办明细(客户、套餐、剩余、加急),数据来自 GET /api/screen/day
- 底部面板改为「今日到期」(左)+「今日待打」(右,加急置顶),最近追加改为最底部细条

数据库兼容性:
- 只做加法:新增 task_flags 表与 packages.calendar_separate 列,原有表结构、字段、接口与已有数据均不变
- 迁移可重复执行(CREATE TABLE IF NOT EXISTS + PRAGMA 检查后 ALTER)

测试:
- 新增单元测试:套餐独立日历标记、加急增删查、旧库自动加列与重复迁移
- 新增功能冒烟 33 项(主题 cookie、套餐标签、快速完成单/多匹配、加急排序、大屏接口)全部通过
- 原有 119 项验收回归全绿(3 条断言按新 UI 更新:抽屉标题、热力默认口径、今日待打数取值方式)
- go vet / go test ./... 通过;版本号 1.2.0
This commit is contained in:
subtrack
2026-10-18 22:07:25 +08:00
parent 099385e3d9
commit 85f98dff5c
23 changed files with 1771 additions and 386 deletions
+171 -56
View File
@@ -3,6 +3,7 @@ package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"time"
@@ -22,6 +23,7 @@ type DayCell struct {
DueCount int
TaskCount int
DoneCount int
UrgentCnt int
Heat int
Tickets []Ticket
More int
@@ -37,6 +39,7 @@ type Ticket struct {
Done bool
Skipped bool
IsDue bool
Urgent bool
}
// TaskRow 是当日待打清单的一行。
@@ -47,30 +50,51 @@ type TaskRow struct {
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
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
NoPackages bool
NoCustomers bool
Back string
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 是日历计算用的订阅上下文。
@@ -108,6 +132,40 @@ func (c calSub) covers(date string) bool {
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())
@@ -118,6 +176,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
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 {
@@ -132,38 +191,23 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
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, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
return
}
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
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),
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
}
if sub.EndDate != "" {
@@ -173,6 +217,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
list = append(list, cs)
}
back := r.URL.RequestURI()
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
@@ -180,7 +225,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: r.URL.RequestURI(),
Back: back,
PkgID: pkgID,
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
@@ -192,8 +238,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
data.NextM = 1
data.NextY = y + 1
}
label := selT.Format("1 月 2 日")
data.SelectedLabel = label + " 周" + dowCN(sel)
data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
@@ -221,6 +266,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
}
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,
@@ -230,6 +279,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent,
}
if t.Done || t.Skipped {
cell.DoneCount++
@@ -237,6 +287,9 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
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
}
@@ -252,6 +305,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
data.MonthUrgent += cell.UrgentCnt
}
week = append(week, cell)
}
@@ -268,8 +322,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if !cs.covers(sel) {
continue
}
v := newSubView(cs.sub, today, warn)
row := TaskRow{SubView: v}
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"
@@ -279,15 +332,13 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
}
tasks = append(tasks, row)
}
sort.SliceStable(tasks, func(i, j int) bool {
ai := tasks[i].Done || tasks[i].Skipped
aj := tasks[j].Done || tasks[j].Skipped
if ai != aj {
return !ai
}
return tasks[i].EndDate < tasks[j].EndDate
})
sortTasks(tasks)
data.Tasks = tasks
for _, row := range tasks {
if !row.Done && !row.Skipped {
data.TaskPending++
}
}
data.Due = due
// 其它日期到期(不在当天的 7 天内)
@@ -296,25 +347,61 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
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 {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
// 空态提示
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
if len(pkgs) == 0 {
data.NoPackages = true
}
@@ -323,6 +410,22 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
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
})
}
func heatLevel(n int) int {
switch {
case n <= 0:
@@ -346,3 +449,15 @@ func shortName(s string) string {
}
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()
}