package web import ( "errors" "net/http" "sort" "strconv" "strings" "daili/internal/domain" "daili/internal/store" ) type customersData struct { List []store.Customer Pager Pager Q string Total int PackageCount int NoPackages bool } 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 } list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn) 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) if err != nil { http.Error(w, "读取客户失败", http.StatusInternalServerError) return } } pkgs, _ := s.db.ListPackages(false) data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0} 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 Timeline *Timeline Extend *ExtendPanel TotalRemain string } 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, 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) } // 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 }) }