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:
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"daili/internal/domain"
|
||||
@@ -61,10 +62,13 @@ type TaskRow struct {
|
||||
|
||||
// PkgTab 是日历上方的套餐标签。
|
||||
type PkgTab struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
ID int64
|
||||
Tag string
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
Href string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type calendarData struct {
|
||||
@@ -93,9 +97,12 @@ type calendarData struct {
|
||||
Back string
|
||||
|
||||
// 套餐标签(在日历中独立显示的套餐)
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
HasTabs bool
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
Tag string
|
||||
// FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用
|
||||
FilterQuery string
|
||||
HasTabs bool
|
||||
|
||||
// 快速完成:按尾号筛选出的候选
|
||||
PickTail string
|
||||
@@ -138,18 +145,76 @@ 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) {
|
||||
// buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。
|
||||
func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total int) ([]PkgTab, bool) {
|
||||
tagOrder := []string{}
|
||||
tagColor := map[string]string{}
|
||||
tagCount := map[string]int{}
|
||||
pkgTag := map[int64]string{}
|
||||
sep := []store.Package{}
|
||||
for _, p := range pkgs {
|
||||
if p.Archived {
|
||||
continue
|
||||
}
|
||||
pkgTag[p.ID] = p.Tag
|
||||
if p.Tag != "" {
|
||||
if _, ok := tagColor[p.Tag]; !ok {
|
||||
tagOrder = append(tagOrder, p.Tag)
|
||||
tagColor[p.Tag] = p.Color
|
||||
}
|
||||
}
|
||||
if p.CalendarSeparate {
|
||||
sep = append(sep, p)
|
||||
}
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if t := pkgTag[sub.PackageID]; t != "" {
|
||||
tagCount[t]++
|
||||
}
|
||||
}
|
||||
if len(tagOrder) == 0 && len(sep) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tabs := []PkgTab{{ID: 0, Name: "全部", Color: "#8A8F98", Count: total}}
|
||||
for _, t := range tagOrder {
|
||||
tabs = append(tabs, PkgTab{Tag: t, Name: t, Color: tagColor[t], Count: tagCount[t]})
|
||||
}
|
||||
for _, p := range sep {
|
||||
cnt := 0
|
||||
for _, sub := range subs {
|
||||
if sub.PackageID == p.ID {
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
tabs = append(tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
|
||||
}
|
||||
return tabs, true
|
||||
}
|
||||
|
||||
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。
|
||||
func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]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 {
|
||||
if pkgID > 0 || tag != "" {
|
||||
pkgs, err := s.db.ListPackages(true)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
tags := make(map[int64]string, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
tags[p.ID] = p.Tag
|
||||
}
|
||||
filtered := make([]store.Subscription, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
if sub.PackageID == pkgID {
|
||||
filtered = append(filtered, sub)
|
||||
if pkgID > 0 && sub.PackageID != pkgID {
|
||||
continue
|
||||
}
|
||||
if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, sub)
|
||||
}
|
||||
subs = filtered
|
||||
}
|
||||
@@ -183,6 +248,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||
|
||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
sel := r.URL.Query().Get("d")
|
||||
selT, err := domain.ParseDate(sel)
|
||||
if sel != "" && err != nil {
|
||||
@@ -201,7 +267,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
|
||||
if err != nil {
|
||||
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -233,6 +299,11 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
SelectedIsToday: sel == domain.Date(today),
|
||||
Back: back,
|
||||
PkgID: pkgID,
|
||||
Tag: tag,
|
||||
FilterQuery: "pkg=" + itoa(pkgID),
|
||||
}
|
||||
if tag != "" {
|
||||
data.FilterQuery = "tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
data.PrevY, data.PrevM = m-1, y
|
||||
if data.PrevM < 1 {
|
||||
@@ -360,30 +431,31 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if pkgID > 0 && u.PackageID != pkgID {
|
||||
continue
|
||||
}
|
||||
if pkgID <= 0 && tag != "" {
|
||||
if p, err := s.db.Package(u.PackageID); err != nil || p.Tag != tag {
|
||||
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})
|
||||
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list))
|
||||
for i := range data.Tabs {
|
||||
t := &data.Tabs[i]
|
||||
href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel)
|
||||
if t.Tag != "" {
|
||||
href += "&tag=" + url.QueryEscape(t.Tag)
|
||||
t.Active = t.Tag == tag
|
||||
} else if t.ID > 0 {
|
||||
href += "&pkg=" + itoa(t.ID)
|
||||
t.Active = t.ID == pkgID
|
||||
} else {
|
||||
t.Active = tag == "" && pkgID == 0
|
||||
}
|
||||
t.Href = href
|
||||
}
|
||||
|
||||
// 快速完成:按尾号列出候选
|
||||
|
||||
Reference in New Issue
Block a user