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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user