首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
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
}
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers")
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
s.render(w, r, "customer_form", p)
}
func 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")),
}
}
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 := readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: true}
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 = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
}
p.D = &customerFormData{Customer: c, IsNew: true}
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 := 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
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)
data := &customerDetailData{
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)
}
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
})
}