Files
subtrack/internal/web/handlers_customers.go
T
subtrack bcce6bb830 fix(v1.8.1): 按次显示已完成/总次数、隐藏已开通套餐、已结束订阅置灰置底
1) 按次计费显示口径
- 订阅卡片、列表、大屏 TOP 都从「剩 x / y 次」改为「已完成 x / y 次」
- 进度条改为按已用次数计算

2) 开通订阅下拉过滤
- 客户详情「开通订阅」下拉自动隐藏该客户已开通的套餐,延期只能走「追加」入口,避免新开叠加
- 若客户已开通全部套餐,下拉只剩占位项

3) 已结束订阅置灰置底
- 客户详情的订阅卡片按「进行中 → 已结束」排序,已结束排到最后
- 已结束卡片降低透明度、降低饱和度,鼠标悬停恢复部分可见

数据库:无变更
测试:
- 功能验证 5 项:按次文案、已开通套餐不在下拉、未开通仍可选、已结束卡片置灰、已结束排在最后
- 119 项验收回归全绿(8 条按次断言随之更新为新口径)
- 无头 Chrome 实拍客户详情页;版本号 1.8.1
2026-10-19 19:53:50 +08:00

520 lines
15 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"
"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()
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
owned := map[int64]bool{}
for _, sub := range subs {
owned[sub.PackageID] = true
}
available := make([]store.Package, 0, len(pkgs))
for _, pkg := range pkgs {
if !owned[pkg.ID] {
available = append(available, pkg)
}
}
sorted := append([]store.Subscription{}, subs...)
sort.SliceStable(sorted, func(i, j int) bool {
ai := sorted[i].Status == domain.StatusClosed
aj := sorted[j].Status == domain.StatusClosed
if ai != aj {
return !ai
}
return sorted[i].ID < sorted[j].ID
})
data := &customerDetailData{
Users: users,
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
Customer: c,
Subs: subViews(sorted, today, warn),
Packages: available,
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
})
}