diff --git a/internal/web/handlers_portal.go b/internal/web/handlers_portal.go new file mode 100644 index 0000000..4044317 --- /dev/null +++ b/internal/web/handlers_portal.go @@ -0,0 +1,243 @@ +package web + +import ( + "fmt" + "net/http" + "strings" + "time" + + "daili/internal/domain" + "daili/internal/store" +) + +// portalDay 自助查询月历中的一天。 +type portalDay struct { + Date string + Day int + InMonth bool + Today bool + Total int + Done int +} + +// portalSub 自助查询里展示的订阅(隐藏备注、打手、价格)。 +type portalSub struct { + Name string + UnitLabel string + Status string + Period string + TodayDone bool +} + +// portalAsk 同一尾号命中多个客户时要求补姓名。 +type portalAsk struct { + Count int + Names []string +} + +// portalView 查询结果。 +type portalView struct { + Tail string + Name string + Game string + Subs []portalSub + Weeks [][]portalDay + MonthTitle string + Prev string + Next string + TodayTotal int + TodayDone int + AllDone bool + ActiveSubs int + Expiring string + TodayPct int + Count int + Names []string +} + +// handleCustomerPortal 客户自助查询页(公开,无入口链接)。 +func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) { + p := s.newPage(r, "排单查询", "") + q := r.URL.Query() + tail := strings.TrimSpace(q.Get("tail")) + name := strings.TrimSpace(q.Get("name")) + month := strings.TrimSpace(q.Get("cm")) + p.Query = tail + if tail == "" { + s.render(w, r, "portal", p) + return + } + + list, err := s.db.CustomersByTail(tail) + if err != nil { + p.Error = "读取客户失败,请稍后再试" + s.render(w, r, "portal", p) + return + } + list = filterActive(list) + if len(list) == 0 { + p.Error = "没有找到这个尾号的客户,请核对尾号" + s.render(w, r, "portal", p) + return + } + if len(list) > 1 { + if name == "" { + names := make([]string, 0, len(list)) + for _, c := range list { + names = append(names, c.Name) + } + p.D = &portalAsk{Count: len(list), Names: names} + s.render(w, r, "portal", p) + return + } + matched := matchByName(list, name) + if len(matched) == 0 { + p.Error = "尾号对得上,但姓名不匹配,请再确认一下" + s.render(w, r, "portal", p) + return + } + if len(matched) > 1 { + names := make([]string, 0, len(matched)) + for _, c := range matched { + names = append(names, c.Name) + } + p.D = &portalAsk{Count: len(matched), Names: names} + s.render(w, r, "portal", p) + return + } + list = matched + } + + c := list[0] + p.D = s.buildPortal(c, tail, name, month) + s.render(w, r, "portal", p) +} + +func filterActive(list []store.Customer) []store.Customer { + out := list[:0] + for _, c := range list { + if !c.Archived { + out = append(out, c) + } + } + return out +} + +func matchByName(list []store.Customer, name string) []store.Customer { + needle := strings.ToLower(strings.TrimSpace(name)) + out := make([]store.Customer, 0, len(list)) + for _, c := range list { + if strings.Contains(strings.ToLower(c.Name), needle) { + out = append(out, c) + } + } + return out +} + +// buildPortal 组装某位客户的排单视图。 +func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView { + today := domain.Today() + todayStr := domain.Date(today) + first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + if month != "" { + if t, err := time.Parse("2006-01", month); err == nil { + first = t + } + } + year, mon := first.Year(), int(first.Month()) + firstDay := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC) + lastDay := firstDay.AddDate(0, 1, -1) + start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7)) + end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7)) + + subs, _ := s.db.SubsByCustomer(c.ID) + active := make([]store.Subscription, 0, len(subs)) + for _, sub := range subs { + if sub.Status != domain.StatusClosed { + active = append(active, sub) + } + } + + cov := func(sub store.Subscription, date string) bool { + if date < sub.StartDate { + return false + } + if sub.EndDate == "" { + return true + } + return date <= sub.EndDate + } + + // 打卡记录 + cks, _ := s.db.CheckinsBetween(domain.Date(start), domain.Date(end)) + + v := &portalView{Tail: c.Tail, Name: c.Name, Game: c.GameName, ActiveSubs: len(active)} + v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon) + q := "?tail=" + tail + if name != "" { + q += "&name=" + name + } + v.Prev = q + "&cm=" + firstDay.AddDate(0, -1, 0).Format("2006-01") + v.Next = q + "&cm=" + firstDay.AddDate(0, 1, 0).Format("2006-01") + + for _, sub := range active { + label := "按日" + if sub.Season { + label = "包赛季" + } else if sub.Unit == domain.UnitCount { + label = "按次" + } + period := sub.StartDate + if sub.EndDate != "" { + period += " → " + sub.EndDate + } else { + period += " 起 · 无有效期" + } + ps := portalSub{Name: sub.PackageName, UnitLabel: label, Status: sub.Status, Period: period} + if m, ok := cks[sub.ID]; ok { + if ck, ok2 := m[todayStr]; ok2 && ck.Status == "done" { + ps.TodayDone = true + } + } + v.Subs = append(v.Subs, ps) + if sub.EndDate != "" && (v.Expiring == "" || sub.EndDate < v.Expiring) { + v.Expiring = sub.EndDate + } + } + + // 月历 + days := int(end.Sub(start).Hours()/24) + 1 + week := make([]portalDay, 0, 7) + for i := 0; i < days; i++ { + d := start.AddDate(0, 0, i) + ds := domain.Date(d) + pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr} + for _, sub := range active { + if !cov(sub, ds) { + continue + } + pd.Total++ + if m, ok := cks[sub.ID]; ok { + if ck, ok2 := m[ds]; ok2 && ck.Status == "done" { + pd.Done++ + } + } + } + if pd.Today { + v.TodayTotal, v.TodayDone = pd.Total, pd.Done + } + week = append(week, pd) + if len(week) == 7 { + v.Weeks = append(v.Weeks, week) + week = make([]portalDay, 0, 7) + } + } + if len(week) > 0 { + v.Weeks = append(v.Weeks, week) + } + v.AllDone = v.TodayTotal > 0 && v.TodayDone >= v.TodayTotal + if v.TodayTotal > 0 { + v.TodayPct = v.TodayDone * 100 / v.TodayTotal + } + return v +} diff --git a/internal/web/server.go b/internal/web/server.go index c95ab0d..966c93c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -156,6 +156,7 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) if s.cfg.ScreenPublic { // 大屏免登录:仅局域网部署时使用,内容为只读看板 + mux.HandleFunc("GET /customer", s.handleCustomerPortal) mux.HandleFunc("GET /screen", s.handleScreen) mux.HandleFunc("GET /api/screen/day", s.handleScreenDay) } else { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4684ed4..3050c9b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1256,3 +1256,25 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } + + +/* ---------- 客户自助查询 /customer ---------- */ +.portal-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; } +.portal-head h1 { font-size: 1.6rem; font-weight: 800; margin: 0 0 4px; background: var(--ink-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.portal-form { border-radius: 1rem; } +.portal-progress .bar { height: 8px; border-radius: 999px; background: var(--ink-1); overflow: hidden; margin-bottom: 6px; } +.portal-progress .bar i { display: block; height: 100%; background: var(--ink-grad); border-radius: 999px; } +.pcal { border: 1px solid var(--ink-line); border-radius: .85rem; overflow: hidden; } +.pcal-dow { display: grid; grid-template-columns: repeat(7, 1fr); background: var(--ink-1); } +.pcal-dow span { text-align: center; padding: 6px 0; font-size: .72rem; color: var(--ink-muted); } +.pcal-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.pcal-cell { min-height: 54px; padding: 6px 8px; border-top: 1px solid var(--ink-line); border-right: 1px solid var(--ink-line); display: flex; flex-direction: column; gap: 2px; } +.pcal-cell:nth-child(7) { border-right: 0; } +.pcal-cell b { font-size: .82rem; color: var(--ink-ink); } +.pcal-cell span { font-size: .72rem; color: var(--ink-green-2); font-weight: 700; } +.pcal-cell.out { opacity: .4; } +.pcal-cell.today { background: var(--ink-grad-soft); box-shadow: inset 0 0 0 2px var(--ink-green-3); } +@media (max-width: 575px) { + .portal-wrap { padding: 16px 10px 32px; } + .pcal-cell { min-height: 46px; padding: 4px 5px; } +} diff --git a/internal/web/templates/pages/portal.html b/internal/web/templates/pages/portal.html new file mode 100644 index 0000000..fe2e278 --- /dev/null +++ b/internal/web/templates/pages/portal.html @@ -0,0 +1,100 @@ +{{define "content"}} +
输入手机尾号查看自己的排单与完成情况{{if not .D}};同一尾号有多位客户时需要再填姓名{{end}}
+同一尾号有多位客户,请在上方补填姓名(支持模糊匹配)后重新查询。
+