package web import ( "errors" "fmt" "net/http" "sort" "strconv" "strings" "time" "daili/internal/domain" "daili/internal/store" ) type customersData struct { List []store.Customer Pager Pager Q string Total int PackageCount int NoPackages bool ShowArchived bool ArchivedCount int } func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) { today := domain.Today() warn := s.warnDays() q := r.URL.Query().Get("q") pageNo := s.queryInt(r, "page", 1) if pageNo < 1 { pageNo = 1 } showArchived := r.URL.Query().Get("archived") == "1" list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn, showArchived) if err != nil { http.Error(w, "读取客户失败", http.StatusInternalServerError) return } pager := newPager(r, pageNo, pageSize, total) if pager.Page != pageNo { list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn, showArchived) if err != nil { http.Error(w, "读取客户失败", http.StatusInternalServerError) return } } pkgs, _ := s.db.ListPackages(false) archivedN, _ := s.db.CountArchivedCustomers() data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0, ShowArchived: showArchived, ArchivedCount: archivedN} p := s.newPage(r, "客户", "customers") p.D = data s.render(w, r, "customers", p) } type customerFormData struct { Customer *store.Customer IsNew bool Users []store.User } func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) { p := s.newPage(r, "新建客户", "customers") users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true, Users: users} s.render(w, r, "customer_form", p) } func (s *Server) readCustomerForm(r *http.Request) *store.Customer { return &store.Customer{ Tail: strings.TrimSpace(r.PostFormValue("tail")), Name: strings.TrimSpace(r.PostFormValue("name")), GameName: strings.TrimSpace(r.PostFormValue("game_name")), GameServer: strings.TrimSpace(r.PostFormValue("game_server")), Contact: strings.TrimSpace(r.PostFormValue("contact")), Tags: normalizeTags(r.PostFormValue("tags")), Source: strings.TrimSpace(r.PostFormValue("source")), Note: strings.TrimSpace(r.PostFormValue("note")), AssigneeID: int64(s.formInt(r, "assignee_id", 0)), } } func normalizeTags(v string) string { parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ',' || r == ' ' || r == ';' || r == ';' }) seen := map[string]bool{} out := []string{} for _, p := range parts { p = strings.TrimSpace(p) if p == "" || seen[p] { continue } seen[p] = true out = append(out, p) } return strings.Join(out, ",") } func validateCustomer(c *store.Customer) string { if c.Tail == "" { return "手机尾号不能为空" } if len([]rune(c.Tail)) > 8 { return "手机尾号最长 8 位" } if c.Name == "" { return "客户名不能为空" } if len([]rune(c.Name)) > 32 { return "客户名最长 32 字" } return "" } func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) { c := s.readCustomerForm(r) p := s.newPage(r, "新建客户", "customers") if msg := validateCustomer(c); msg != "" { users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true, Users: users} s.fail(w, r, "customer_form", msg, p) return } id, err := s.db.CreateCustomer(c) if err != nil { msg := "创建失败:" + err.Error() if errors.Is(err, store.ErrDuplicate) { msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅" } users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true, Users: users} s.fail(w, r, "customer_form", msg, p) return } s.logOp(r, "新建客户", "customer", id, "新建客户 "+c.Tail+" "+c.Name) s.redirect(w, r, "/customers/"+itoa(id), "客户已创建,接着为他添加订阅") } func (s *Server) handleCustomerEditPage(w http.ResponseWriter, r *http.Request) { c, err := s.db.Customer(pathID(r)) if err != nil { http.NotFound(w, r) return } p := s.newPage(r, "编辑客户", "customers") p.D = &customerFormData{Customer: c, IsNew: false} s.render(w, r, "customer_form", p) } func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) { id := pathID(r) old, err := s.db.Customer(id) if err != nil { http.NotFound(w, r) return } c := s.readCustomerForm(r) c.ID = id p := s.newPage(r, "编辑客户", "customers") if msg := validateCustomer(c); msg != "" { p.D = &customerFormData{Customer: c, IsNew: false} s.fail(w, r, "customer_form", msg, p) return } if err := s.db.UpdateCustomer(c); err != nil { msg := "保存失败:" + err.Error() if errors.Is(err, store.ErrDuplicate) { msg = "该「尾号 + 姓名」的客户已存在" } p.D = &customerFormData{Customer: c, IsNew: false} s.fail(w, r, "customer_form", msg, p) return } s.logOp(r, "编辑客户", "customer", id, "修改客户 "+c.Tail+" "+c.Name+"(原 "+old.Tail+" "+old.Name+")") s.redirect(w, r, "/customers/"+itoa(id), "客户资料已保存") } // CheckinRow 是打卡历史的展示行。 type CheckinRow struct { store.Checkin SubName string SubColor string } type customerDetailData struct { Customer *store.Customer Subs []SubView Adjustments []store.Adjustment AdjSubName map[int64]string AdjSubColor map[int64]string 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) if err != nil { http.NotFound(w, r) return } today := domain.Today() warn := s.warnDays() subs, err := s.db.SubsByCustomer(id) if err != nil { http.Error(w, "读取订阅失败", http.StatusInternalServerError) return } pkgs, _ := s.db.ListPackages(false) ids := make([]int64, 0, len(subs)) subByID := map[int64]store.Subscription{} for _, sub := range subs { ids = append(ids, sub.ID) subByID[sub.ID] = sub } pauses, _ := s.db.PausesForSubs(ids) 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, Timeline: buildTimeline(subs, pauses, today), AdjSubName: map[int64]string{}, AdjSubColor: map[int64]string{}, } for _, sub := range subs { data.AdjSubName[sub.ID] = sub.PackageName data.AdjSubColor[sub.ID] = sub.PackageColor adj, err := s.db.AdjustmentsBySub(sub.ID) if err == nil { data.Adjustments = append(data.Adjustments, adj...) } if ci, err := s.db.CheckinsForSub(sub.ID, 30); err == nil { for _, one := range ci { data.Checkins = append(data.Checkins, CheckinRow{Checkin: one, SubName: sub.PackageName, SubColor: sub.PackageColor}) } } } // 按时间排序(新→旧) sortAdjustments(data.Adjustments) sortCheckins(data.Checkins) if eid := s.queryInt(r, "extend", 0); eid > 0 { if sub, ok := subByID[int64(eid)]; ok { 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) } } } p := s.newPage(r, c.Tail+" "+c.Name, "customers") p.D = data s.render(w, r, "customer_detail", p) } // handleCustomerArchive 停用客户:默认从列表、日历、大屏隐藏,订阅保留。 func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) { id := pathID(r) c, err := s.db.Customer(id) if err != nil { http.NotFound(w, r) return } if err := s.db.SetCustomerArchived(id, true); err != nil { s.redirect(w, r, "/customers/"+itoa(id), "停用失败:"+err.Error()) return } s.logOp(r, "停用客户", "customer", id, c.Tail+" "+c.Name+"(订阅保留,不再上日历)") s.redirect(w, r, "/customers", "已停用客户 "+c.Tail+" "+c.Name+",可在「已停用」里恢复") } // handleCustomerRestore 恢复客户,订单一并回到日历。 func (s *Server) handleCustomerRestore(w http.ResponseWriter, r *http.Request) { id := pathID(r) c, err := s.db.Customer(id) if err != nil { http.NotFound(w, r) return } if err := s.db.SetCustomerArchived(id, false); err != nil { s.redirect(w, r, "/customers/"+itoa(id), "恢复失败:"+err.Error()) return } s.logOp(r, "恢复客户", "customer", id, c.Tail+" "+c.Name) s.redirect(w, r, "/customers/"+itoa(id), "已恢复客户 "+c.Tail+" "+c.Name) } // handleCustomerDelete 彻底删除客户及其订阅/打卡/追加记录(外键级联)。 func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) { id := pathID(r) c, err := s.db.Customer(id) if err != nil { http.NotFound(w, r) return } if r.PostFormValue("confirm") != "delete" { s.redirect(w, r, "/customers/"+itoa(id), "请在弹窗里确认删除") return } if err := s.db.DeleteCustomer(id); err != nil { s.redirect(w, r, "/customers/"+itoa(id), "删除失败:"+err.Error()) return } s.logOp(r, "删除客户", "customer", id, c.Tail+" "+c.Name+"(含订阅与打卡记录)") s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name) } // handleCustomerAssignee 从客户详情快速指派/取消打手。 func (s *Server) handleCustomerAssignee(w http.ResponseWriter, r *http.Request) { id := pathID(r) userID := int64(s.formInt(r, "assignee_id", 0)) if err := s.db.SetCustomerAssignee(id, userID); err != nil { s.redirect(w, r, "/customers/"+itoa(id), "指派失败:"+err.Error()) return } c, _ := s.db.Customer(id) word := "已取消指派" if userID > 0 { if u, err := s.db.UserByID(userID); err == nil { name := u.DisplayName if name == "" { name = u.Username } word = "已指派给 " + name } } if c != nil { s.logOp(r, "指派打手", "customer", id, c.Tail+" "+c.Name+" "+word) } s.redirect(w, r, "/customers/"+itoa(id), word) } func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) { customerID := pathID(r) c, err := s.db.Customer(customerID) if err != nil { http.NotFound(w, r) return } in := store.CreateSubInput{ CustomerID: customerID, PackageID: int64(s.formInt(r, "package_id", 0)), StartDate: r.PostFormValue("start_date"), EndDate: r.PostFormValue("end_date"), Days: s.formInt(r, "days", 0), TotalCount: s.formInt(r, "total_count", 0), Note: strings.TrimSpace(r.PostFormValue("note")), } u := s.currentUser(r) if u != nil { in.OperatorID = u.ID } sub, err := s.db.CreateSub(in) if err != nil { s.redirect(w, r, "/customers/"+itoa(customerID), "创建订阅失败:"+err.Error()) return } s.logOp(r, "新建订阅", "subscription", sub.ID, "为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")") s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」") } func unitLabel(u string) string { if u == domain.UnitCount { return "按次" } return "按日" } func itoa(v int64) string { return strconv.FormatInt(v, 10) } func sortAdjustments(list []store.Adjustment) { sort.SliceStable(list, func(i, j int) bool { if list[i].CreatedAt != list[j].CreatedAt { return list[i].CreatedAt > list[j].CreatedAt } return list[i].ID > list[j].ID }) } func sortCheckins(list []CheckinRow) { sort.SliceStable(list, func(i, j int) bool { if list[i].ServiceDate != list[j].ServiceDate { return list[i].ServiceDate > list[j].ServiceDate } return list[i].ID > list[j].ID }) }