1) 日历计数口径
- 工作台与大屏的格子统一改为「已完成 / 总数」(如 2/5 = 共 5 单、做完 2 单)
2) 客户详情·当月排单
- 新增客户专属月历:每天显示 已完成/总数,可翻月,点某天跳转到日历对应日期
- 没有排单的日期淡化显示
3) 套餐标签与页签合并
- packages 新增 tag 列(可空),套餐编辑页可填
- 日历/大屏页签按标签合并:同标签套餐合成一个页签;勾了「独立显示」的套餐额外再开一个自己的页签
- 大屏同样支持标签筛选,链接参数为 tag=xxx
4) 批量开通
- 新增套餐详情页 /packages/{id}:显示套餐信息 + 客户列表
- 表头「全选」置顶,已订阅该套餐的客户置灰不可选;开始日期默认今天、天数/次数默认取套餐设置,均可改
- POST /packages/{id}/subscribe 批量开通,重复的自动跳过并汇总提示
- 修复 r.PostForm 未 ParseForm 导致拿不到勾选项的问题(批量打卡同源问题一并修正)
5) 触控 / PC 模式
- 尾号输入框右侧新增切换按钮,触控模式展开 3×4 键盘、输入框只读 + inputmode=none(不唤起系统输入法),按键直接写值
- 偏好记在 localStorage,默认 PC 模式(键盘收起)
6) 按次到期
- 按次订阅的到期日保持选填;填了到期后状态显示「已到期」且不再进日历待排(复用既有 end_date,无新列)
数据库兼容性:
- 只新增 packages.tag 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变
测试:
- 无头 Chrome 实拍 5 张页面(日历/触控模式/客户月历/套餐批量/大屏)
- 功能验证 18 项全过(计数口径、标签页签、标签筛选、批量开通与重复跳过、月历翻月、触控开关、按次过期不上日历)
- 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.7.0
This commit is contained in:
@@ -2,10 +2,12 @@ package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"daili/internal/domain"
|
||||
"daili/internal/store"
|
||||
@@ -190,11 +192,112 @@ type customerDetailData struct {
|
||||
Checkins []CheckinRow
|
||||
Packages []store.Package
|
||||
Users []store.User
|
||||
CustCal *custCal
|
||||
Timeline *Timeline
|
||||
Extend *ExtendPanel
|
||||
TotalRemain string
|
||||
}
|
||||
|
||||
// custCalDay 客户月历的一格。
|
||||
type custCalDay struct {
|
||||
Date string
|
||||
Day int
|
||||
InMonth bool
|
||||
IsToday bool
|
||||
Count int
|
||||
Done int
|
||||
AllDone bool
|
||||
}
|
||||
|
||||
// custCal 客户详情页的当月排单日历。
|
||||
type custCal struct {
|
||||
Year, Month int
|
||||
Title string
|
||||
Prev, Next string
|
||||
Weekdays []string
|
||||
Weeks [][]custCalDay
|
||||
TaskDays int
|
||||
TotalTasks int
|
||||
}
|
||||
|
||||
func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time) *custCal {
|
||||
y, m := today.Year(), int(today.Month())
|
||||
if t, err := time.Parse("2006-01", cm); err == nil {
|
||||
y, m = t.Year(), int(t.Month())
|
||||
}
|
||||
monthStart := domain.MonthStart(y, time.Month(m))
|
||||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
|
||||
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||
|
||||
subs, err := s.db.SubsByCustomer(c.ID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
ids = append(ids, sub.ID)
|
||||
}
|
||||
pauses, _ := s.db.PausesForSubs(ids)
|
||||
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
|
||||
warn := s.warnDays()
|
||||
list := make([]calSub, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)}
|
||||
if sub.EndDate != "" {
|
||||
cs.hasEnd = true
|
||||
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||||
}
|
||||
list = append(list, cs)
|
||||
}
|
||||
|
||||
cal := &custCal{
|
||||
Year: y, Month: m,
|
||||
Title: fmt.Sprintf("%d 年 %d 月", y, m),
|
||||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||
}
|
||||
py, pm := y, m-1
|
||||
if pm < 1 {
|
||||
pm = 12
|
||||
py--
|
||||
}
|
||||
ny, nm := y, m+1
|
||||
if nm > 12 {
|
||||
nm = 1
|
||||
ny++
|
||||
}
|
||||
cal.Prev = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, py, pm)
|
||||
cal.Next = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, ny, nm)
|
||||
|
||||
for wi := 0; wi < weeks; wi++ {
|
||||
week := make([]custCalDay, 0, 7)
|
||||
for di := 0; di < 7; di++ {
|
||||
d := domain.AddDays(gridStart, wi*7+di)
|
||||
ds := domain.Date(d)
|
||||
cell := custCalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(m), IsToday: ds == domain.Date(today)}
|
||||
for _, cs := range list {
|
||||
if !cs.covers(ds) {
|
||||
continue
|
||||
}
|
||||
cell.Count++
|
||||
if ci, ok := checkins[cs.sub.ID][ds]; ok && (ci.Status == "done" || ci.Status == "skipped") {
|
||||
cell.Done++
|
||||
}
|
||||
}
|
||||
if cell.Count > 0 {
|
||||
cell.AllDone = cell.Done >= cell.Count
|
||||
if cell.InMonth {
|
||||
cal.TaskDays++
|
||||
cal.TotalTasks += cell.Count
|
||||
}
|
||||
}
|
||||
week = append(week, cell)
|
||||
}
|
||||
cal.Weeks = append(cal.Weeks, week)
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r)
|
||||
c, err := s.db.Customer(id)
|
||||
@@ -222,6 +325,7 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||
users, _ := s.db.ActiveUsers()
|
||||
data := &customerDetailData{
|
||||
Users: users,
|
||||
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
||||
Customer: c,
|
||||
Subs: subViews(subs, today, warn),
|
||||
Packages: pkgs,
|
||||
|
||||
Reference in New Issue
Block a user