Files
subtrack/internal/web/handlers_customers.go
T
subtrack 8cfc9f5891 feat(v1.3): 管理员(打手)颜色,客户归属打手,日历/待办按颜色标识
1) 管理员颜色
- users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效
- 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝

2) 客户归属打手
- customers 表新增 assignee_id(可空,指向 users)
- 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派
- 语义:一个客户一个打手,该客户之后的所有单子都归这个打手
- 停用的账户不出现在打手候选里;未指派显示灰色「未指派」

3) 日历
- 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段)
- 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响
- 未指派的任务用中性灰段落

4) 待办与大屏
- 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字
- 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色
- 加急样式改为红底,避免与打手色左边框冲突

数据库兼容性:
- 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移
- 原有表结构、字段、页面接口不变;未指派的存量数据照常工作

测试:
- 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选
- 旧库迁移测试扩展:老 users / customers 表自动补列并可写
- 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
2026-10-19 00:16:34 +08:00

339 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})
}