diff --git a/README.md b/README.md index 0979bdd..8b6cad9 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ - **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复; - **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅; - **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期; +- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。 +- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。 - **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响; - **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识: - 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示); @@ -152,6 +154,15 @@ --- +## 4.2 套餐标签与批量开通 + +- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。 +- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。 +- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。 +- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。 + +--- + ## 5. 数据、备份与导出 | 文件 | 说明 | @@ -164,7 +175,7 @@ - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 -- 客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; +- 套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; diff --git a/internal/store/db.go b/internal/store/db.go index 4560cbf..af8dad0 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -134,6 +134,11 @@ func (db *DB) Migrate() error { "ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil { return err } + // v1.7:套餐标签(同标签的套餐在日历里合并成一个页签) + if err := db.ensureColumn("packages", "tag", + "ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } return nil } @@ -196,6 +201,7 @@ var schemaStatements = []string{ archived INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, calendar_separate INTEGER NOT NULL DEFAULT 0, + tag TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, diff --git a/internal/store/packages.go b/internal/store/packages.go index 1b56873..d2bb1d0 100644 --- a/internal/store/packages.go +++ b/internal/store/packages.go @@ -22,10 +22,12 @@ type Package struct { SortOrder int // CalendarSeparate 勾选后在工作台/大屏日历中单独成页 CalendarSeparate bool - CreatedAt string - UpdatedAt string - SubCount int - ActiveCount int + // Tag 套餐标签:相同标签的套餐在日历里合并显示 + Tag string + CreatedAt string + UpdatedAt string + SubCount int + ActiveCount int } // UnitLabel 返回类型中文名。 @@ -41,7 +43,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { var price sql.NullInt64 var archived, separate int err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt) + &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -59,7 +61,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { // ListPackages 列出套餐(含订阅计数)。 func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { - q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.created_at,p.updated_at, + q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.created_at,p.updated_at, (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id), (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed') FROM packages p` @@ -78,7 +80,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { var price sql.NullInt64 var archived, separate int if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { + &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { return nil, err } p.Archived = archived == 1 @@ -94,7 +96,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { // Package 取单个套餐。 func (db *DB) Package(id int64) (*Package, error) { - return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at + return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at FROM packages WHERE id=?`, id)) } @@ -112,9 +114,9 @@ func (db *DB) CreatePackage(p *Package) (int64, error) { if p.HasPrice { price = p.PriceCents } - res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at) - VALUES(?,?,?,?,?,?,0,?,?,?,?)`, - p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), now, now) + res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`, + p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now) if err != nil { return 0, err } @@ -134,8 +136,8 @@ func (db *DB) UpdatePackage(p *Package) error { if p.HasPrice { price = p.PriceCents } - _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,updated_at=? - WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), nowStamp(), p.ID) + _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=? + WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID) return err } @@ -156,3 +158,39 @@ func (db *DB) SetPackageArchived(id int64, archived bool) error { _, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id) return err } + +// SubscribedCustomerIDs 返回已订阅某套餐的客户 ID 集合(含已关闭,用于批量订阅时置灰)。 +func (db *DB) SubscribedCustomerIDs(packageID int64) (map[int64]bool, error) { + rows, err := db.sql.Query(`SELECT customer_id FROM subscriptions WHERE package_id=?`, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]bool{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + +// PackageTags 返回所有非空标签(去重,按字典序)。 +func (db *DB) PackageTags() ([]string, error) { + rows, err := db.sql.Query(`SELECT DISTINCT tag FROM packages WHERE IFNULL(tag,'')<>'' ORDER BY tag`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 09bdca4..1167241 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1106,3 +1106,39 @@ func TestCustomerArchiveFilters(t *testing.T) { t.Fatal("删除不存在的客户应报错") } } + +func TestPackageTag(t *testing.T) { + db := newTestDB(t) + + p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 30) + if p1.Tag != "" { + t.Fatalf("默认标签应为空: %q", p1.Tag) + } + p1.Tag = "包月" + if err := db.UpdatePackage(p1); err != nil { + t.Fatal(err) + } + got, err := db.Package(p1.ID) + if err != nil || got.Tag != "包月" { + t.Fatalf("标签未保存: %+v err=%v", got, err) + } + + p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "} + if _, err := db.CreatePackage(p2); err != nil { + t.Fatal(err) + } + tags, err := db.PackageTags() + if err != nil || len(tags) != 1 || tags[0] != "包月" { + t.Fatalf("标签去重/去空格失败: %+v err=%v", tags, err) + } + + list, err := db.ListPackages(true) + if err != nil || len(list) != 2 { + t.Fatalf("列表应有 2 个套餐: %d err=%v", len(list), err) + } + for _, p := range list { + if p.Tag != "包月" { + t.Fatalf("列表标签不对: %+v", p) + } + } +} diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go index b5422bc..5b6bc44 100644 --- a/internal/web/handlers_calendar.go +++ b/internal/web/handlers_calendar.go @@ -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 } // 快速完成:按尾号列出候选 diff --git a/internal/web/handlers_customers.go b/internal/web/handlers_customers.go index 6771944..d1387a4 100644 --- a/internal/web/handlers_customers.go +++ b/internal/web/handlers_customers.go @@ -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, diff --git a/internal/web/handlers_packages.go b/internal/web/handlers_packages.go index 3af46e2..cc83023 100644 --- a/internal/web/handlers_packages.go +++ b/internal/web/handlers_packages.go @@ -1,6 +1,7 @@ package web import ( + "fmt" "net/http" "strconv" "strings" @@ -27,13 +28,16 @@ func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) { type packageFormData struct { Package *store.Package IsNew bool + Tags []string } func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) { p := s.newPage(r, "新增套餐", "packages") + tags, _ := s.db.PackageTags() p.D = &packageFormData{ Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"}, IsNew: true, + Tags: tags, } s.render(w, r, "package_form", p) } @@ -63,6 +67,7 @@ func readPackageForm(r *http.Request) *store.Package { p.Unit = domain.UnitDay } p.CalendarSeparate = r.PostFormValue("calendar_separate") != "" + p.Tag = strings.TrimSpace(r.PostFormValue("tag")) return p } @@ -85,17 +90,123 @@ func validatePackage(p *store.Package) string { return "" } +// packageDetailData 套餐详情页数据。 +type packageDetailData struct { + Package *store.Package + Customers []store.Customer + Subscribed map[int64]bool + Q string + Tags []string + Today string +} + +// handlePackageDetail 套餐详情:查看信息 + 勾选客户批量开通。 +func (s *Server) handlePackageDetail(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + pkg, err := s.db.Package(id) + if err != nil { + http.NotFound(w, r) + return + } + today := domain.Today() + warn := s.warnDays() + q := strings.TrimSpace(r.URL.Query().Get("q")) + list, _, err := s.db.ListCustomers(q, 300, 0, domain.Date(today), warn, false) + if err != nil { + http.Error(w, "读取客户失败", http.StatusInternalServerError) + return + } + subscribed, err := s.db.SubscribedCustomerIDs(id) + if err != nil { + http.Error(w, "读取订阅失败", http.StatusInternalServerError) + return + } + tags, _ := s.db.PackageTags() + p := s.newPage(r, "套餐详情", "packages") + p.D = &packageDetailData{Package: pkg, Customers: list, Subscribed: subscribed, Q: q, Tags: tags, Today: domain.Date(today)} + s.render(w, r, "package_detail", p) +} + +// handlePackageBatchSubscribe 给勾选的客户批量开通该套餐。 +func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + pkg, err := s.db.Package(id) + if err != nil { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + back := "/packages/" + itoa(id) + raw := r.PostForm["customer_ids"] + if len(raw) == 0 { + s.redirect(w, r, back, "没有选中任何客户") + return + } + start := strings.TrimSpace(r.PostFormValue("start_date")) + if start == "" { + start = domain.Date(domain.Today()) + } + if _, err := domain.ParseDate(start); err != nil { + s.redirect(w, r, back, "开始日期格式不正确") + return + } + end := strings.TrimSpace(r.PostFormValue("end_date")) + days := s.formInt(r, "days", 0) + count := s.formInt(r, "total_count", 0) + u := s.currentUser(r) + var op int64 + if u != nil { + op = u.ID + } + + done, skipped, failed := 0, 0, 0 + var lastErr string + for _, v := range raw { + cid, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + failed++ + continue + } + _, err = s.db.CreateSub(store.CreateSubInput{ + CustomerID: cid, PackageID: id, StartDate: start, EndDate: end, + Days: days, TotalCount: count, OperatorID: op, + }) + if err != nil { + if strings.Contains(err.Error(), "已订阅") { + skipped++ + continue + } + failed++ + lastErr = err.Error() + continue + } + done++ + } + s.logOp(r, "批量开通", "package", id, + fmt.Sprintf("%s:成功 %d、跳过 %d、失败 %d", pkg.Name, done, skipped, failed)) + msg := fmt.Sprintf("已为 %d 位客户开通「%s」", done, pkg.Name) + if skipped > 0 { + msg += fmt.Sprintf(",%d 位已有该套餐已跳过", skipped) + } + if failed > 0 { + msg += fmt.Sprintf(",%d 位失败(%s)", failed, lastErr) + } + s.redirect(w, r, back, msg) +} + func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) { pkg := readPackageForm(r) p := s.newPage(r, "新增套餐", "packages") if msg := validatePackage(pkg); msg != "" { - p.D = &packageFormData{Package: pkg, IsNew: true} + tags, _ := s.db.PackageTags() + p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags} s.fail(w, r, "package_form", msg, p) return } id, err := s.db.CreatePackage(pkg) if err != nil { - p.D = &packageFormData{Package: pkg, IsNew: true} + tags, _ := s.db.PackageTags() + p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags} s.fail(w, r, "package_form", "保存失败:"+err.Error(), p) return } @@ -111,7 +222,8 @@ func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) { return } p := s.newPage(r, "编辑套餐", "packages") - p.D = &packageFormData{Package: pkg, IsNew: false} + tags, _ := s.db.PackageTags() + p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags} s.render(w, r, "package_form", p) } @@ -121,12 +233,14 @@ func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) { pkg.ID = id p := s.newPage(r, "编辑套餐", "packages") if msg := validatePackage(pkg); msg != "" { - p.D = &packageFormData{Package: pkg, IsNew: false} + tags, _ := s.db.PackageTags() + p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags} s.fail(w, r, "package_form", msg, p) return } if err := s.db.UpdatePackage(pkg); err != nil { - p.D = &packageFormData{Package: pkg, IsNew: false} + tags, _ := s.db.PackageTags() + p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags} s.fail(w, r, "package_form", "保存失败:"+err.Error(), p) return } diff --git a/internal/web/handlers_screen.go b/internal/web/handlers_screen.go index ae9320c..8064879 100644 --- a/internal/web/handlers_screen.go +++ b/internal/web/handlers_screen.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "daili/internal/domain" "daili/internal/store" @@ -16,6 +18,7 @@ type screenDay struct { IsToday bool IsPast bool Count int + Done int Urgent int Heat int // Strip 打手色竖条(去重) @@ -42,17 +45,19 @@ type screenData struct { Refresh int Version string - HeatMode string // task = 每日待完成;due = 到期 - HeatTotal int - Tabs []PkgTab - PkgID int64 - HasTabs bool - Theme string + HeatMode string // task = 每日待完成;due = 到期 + Tag string + HeatTotal int + Tabs []PkgTab + PkgID int64 + HasTabs bool + FilterQuery string + Theme string } // screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。 -func (s *Server) screenCalSubs(from, to string, pkgID int64) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { - subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID) +func (s *Server) screenCalSubs(from, to string, pkgID int64, tag string) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { + subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID, tag) if err != nil { return nil, nil, nil, err } @@ -74,6 +79,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { today := domain.Today() warn := s.warnDays() pkgID := int64(s.queryInt(r, "pkg", 0)) + tag := strings.TrimSpace(r.URL.Query().Get("tag")) heatMode := r.URL.Query().Get("heat") if heatMode != "due" { heatMode = "task" @@ -85,16 +91,18 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { return } data := &screenData{ - Stats: stats, - MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())), - Year: today.Year(), - Month: int(today.Month()), - Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, - Top: subViews(stats.Top, today, warn), - Refresh: s.screenRefresh(), - Version: s.cfg.Version, - HeatMode: heatMode, - PkgID: pkgID, + Stats: stats, + MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())), + Year: today.Year(), + Month: int(today.Month()), + Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, + Top: subViews(stats.Top, today, warn), + Refresh: s.screenRefresh(), + Version: s.cfg.Version, + HeatMode: heatMode, + PkgID: pkgID, + Tag: tag, + FilterQuery: "pkg=" + itoa(pkgID), } // 今日到期(可按套餐过滤) @@ -114,7 +122,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7 gridEnd := domain.AddDays(gridStart, weeks*7-1) - list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID) + list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag) if err != nil { http.Error(w, "读取大屏日历失败", http.StatusInternalServerError) return @@ -125,7 +133,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { for di := 0; di < 7; di++ { d := domain.AddDays(gridStart, wi*7+di) ds := domain.Date(d) - count, urgentN := 0, 0 + count, doneN, urgentN := 0, 0, 0 strip := []string{} seenColor := map[string]bool{} for _, cs := range list { @@ -140,7 +148,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { } ci, has := checkins[cs.sub.ID][ds] if has && (ci.Status == "done" || ci.Status == "skipped") { - continue // “待完成”只统计还没处理的 + doneN++ + continue // “待完成”热力只统计还没处理的 } count++ if urgent[cs.sub.ID][ds] { @@ -164,7 +173,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { InMonth: d.Month() == today.Month(), IsToday: ds == domain.Date(today), IsPast: d.Before(today), - Count: count, + Count: count + doneN, + Done: doneN, Urgent: urgentN, Heat: heatLevel(count), Strip: strip, @@ -174,7 +184,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { } // 今日待打(加急置顶) - rows, err := s.pendingTasks(domain.Date(today), nil, pkgID) + rows, err := s.pendingTasks(domain.Date(today), nil, pkgID, tag) if err == nil { for _, row := range rows { if row.Done || row.Skipped { @@ -198,18 +208,27 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { data.Recent = adj } - // 套餐标签 + // 页签:按标签合并 + 独立显示另开 pkgs, _ := s.db.ListPackages(true) - for _, p := range pkgs { - if p.CalendarSeparate && !p.Archived { - if !data.HasTabs { - data.HasTabs = true - data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98"}) - } - data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color}) + data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0) + for i := range data.Tabs { + t := &data.Tabs[i] + href := "/screen?heat=" + heatMode + 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 } + if tag != "" { + data.FilterQuery = "tag=" + url.QueryEscape(tag) + } data.Theme = themeFromRequest(r) p := s.newPage(r, "大屏", "screen") p.Theme = data.Theme @@ -225,7 +244,7 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) { return } pkgID := int64(s.queryInt(r, "pkg", 0)) - rows, err := s.pendingTasks(date, nil, pkgID) + rows, err := s.pendingTasks(date, nil, pkgID, strings.TrimSpace(r.URL.Query().Get("tag"))) if err != nil { http.Error(w, "读取失败", http.StatusInternalServerError) return diff --git a/internal/web/handlers_tasks.go b/internal/web/handlers_tasks.go index b3ebc1a..c4a76a4 100644 --- a/internal/web/handlers_tasks.go +++ b/internal/web/handlers_tasks.go @@ -11,8 +11,8 @@ import ( // pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余), // customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。 -func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64) ([]TaskRow, error) { - subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID) +func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) { + subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag) if err != nil { return nil, err } @@ -51,6 +51,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) { tail := strings.TrimSpace(r.PostFormValue("tail")) date := strings.TrimSpace(r.PostFormValue("date")) pkgID := int64(s.formInt(r, "pkg", 0)) + tag := strings.TrimSpace(r.PostFormValue("tag")) if _, err := domain.ParseDate(date); err != nil { date = domain.Date(domain.Today()) } @@ -74,7 +75,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) { ids[c.ID] = true } - rows, err := s.pendingTasks(date, ids, pkgID) + rows, err := s.pendingTasks(date, ids, pkgID, tag) if err != nil { s.redirect(w, r, back, "读取待完成失败:"+err.Error()) return @@ -127,6 +128,7 @@ func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) { date = domain.Date(domain.Today()) } back := backURL(r, "/calendar?d="+date) + _ = r.ParseForm() raw := r.PostForm["sub_ids"] if len(raw) == 0 { s.redirect(w, r, back, "没有选中任何项") diff --git a/internal/web/server.go b/internal/web/server.go index 74f50b5..0d407df 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -126,6 +126,8 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage)) mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) + mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail)) + mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) if s.cfg.ScreenPublic { // 大屏免登录:仅局域网部署时使用,内容为只读看板 mux.HandleFunc("GET /screen", s.handleScreen) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8922424..d382734 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1044,3 +1044,52 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } margin: 6px 0 2px; padding-left: 9px; border-left: 1px dashed var(--line); } + + +/* ---------- v1.7:客户详情月历 ---------- */ + +.minical-nav { display: flex; align-items: center; gap: 8px; } +.minical-nav b { font-family: var(--mono); font-size: 13px; } + +.minical { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + max-width: 560px; +} +.minical-dow { + display: grid; grid-template-columns: repeat(7, 1fr); + background: var(--paper); border-bottom: 1px solid var(--line); +} +.minical-dow span { padding: 5px 0; text-align: center; font-size: 11px; color: var(--muted); } +.minical-row { display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 54px; } + +.minical-cell { + position: relative; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 1px; min-height: 0; + border-right: 1px solid var(--line-2); + border-bottom: 1px solid var(--line-2); + color: var(--ink); text-decoration: none; + font-size: 12px; +} +.minical-cell:nth-child(7n) { border-right: 0; } +.minical-cell:hover { background: var(--hover); text-decoration: none; } +.minical-cell.out { opacity: 0.35; } +.minical-cell.today { box-shadow: inset 0 0 0 2px var(--accent); } +.minical-cell.no-task { color: var(--muted); } +.minical-cell .mc-day { font-family: var(--mono); font-size: 12px; font-weight: 600; } +.minical-cell .mc-count { + font-family: var(--mono); font-size: 10px; + color: var(--accent); line-height: 1; +} +.minical-cell.all-done .mc-count { color: var(--muted); } +.minical-cell.all-done .mc-day { color: var(--muted); } +.minical-cell.today .mc-day { color: var(--accent); } + +/* 触控模式按钮 */ +.keypad-toggle { min-width: 40px; padding: 0 8px; font-size: 12px; } +.keypad-toggle.on { + background: var(--accent); border-color: var(--accent); color: var(--paper); +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 27b31f8..7454160 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -198,3 +198,58 @@ }); }); })(); + + +/* ---------- 全选 + 触控/PC 小键盘模式 ---------- */ +(function () { + 'use strict'; + + /* 批量开通:表头全选 */ + Array.prototype.forEach.call(document.querySelectorAll('[data-select-all]'), function (box) { + box.addEventListener('change', function () { + var form = box.closest('form') || document; + Array.prototype.forEach.call(form.querySelectorAll('input[name="customer_ids"]'), function (c) { + if (!c.disabled) { c.checked = box.checked; } + }); + }); + }); + + /* 触控 / PC 模式:触控时显示键盘并禁用输入框(不唤起系统输入法) */ + var KEY = 'daili_keypad_mode'; + + function applyMode(touch) { + Array.prototype.forEach.call(document.querySelectorAll('[data-keypad]'), function (pad) { + pad.hidden = !touch; + var block = pad.closest('.drawer-block') || document; + var input = block.querySelector('input[name="tail"]'); + if (input) { + input.readOnly = touch; + input.setAttribute('inputmode', touch ? 'none' : 'numeric'); + input.setAttribute('placeholder', touch ? '点下方键盘输入尾号' : '输入手机尾号,回车即可完成'); + } + var btn = block.querySelector('[data-keypad-toggle]'); + if (btn) { + btn.textContent = touch ? '触控' : '⌨'; + btn.classList.toggle('on', touch); + btn.title = touch ? '当前触控模式,点此切回 PC' : '当前 PC 模式,点此打开触控键盘'; + } + }); + } + + var touchMode = false; + try { touchMode = localStorage.getItem(KEY) === 'touch'; } catch (err) { } + applyMode(touchMode); + + Array.prototype.forEach.call(document.querySelectorAll('[data-keypad-toggle]'), function (btn) { + btn.addEventListener('click', function () { + touchMode = !touchMode; + try { localStorage.setItem(KEY, touchMode ? 'touch' : 'pc'); } catch (err) { } + applyMode(touchMode); + if (!touchMode) { + var block = btn.closest('.drawer-block') || document; + var input = block.querySelector('input[name="tail"]'); + if (input) { input.focus(); } + } + }); + }); +})(); diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html index 46a73fd..e6739b6 100644 --- a/internal/web/templates/pages/calendar.html +++ b/internal/web/templates/pages/calendar.html @@ -2,10 +2,10 @@
+ {{if eq .D.Package.Unit "day"}}按日{{else}}按次{{end}} · + 默认 {{.D.Package.DefaultAmount}}{{if eq .D.Package.Unit "day"}} 天{{else}} 次{{end}} · + {{if .D.Package.Tag}}标签「{{.D.Package.Tag}}」 · {{end}}{{if .D.Package.CalendarSeparate}}独立日历 · {{end}} + {{if .D.Package.Archived}}已归档{{end}} +
+