Files
subtrack/internal/web/handlers_portal.go
T
subtrack c84175b723 feat(v1.10.0): 新增客户自助查询页 /customer
需求与确认:
- 完全公开、不加入口链接(用户确认)
- 输入手机尾号查询;同一尾号命中多个客户时必须补姓名,模糊匹配(用户确认)
- 只展示排单与完成情况:月历每日 已完成/总数、订阅套餐名与类型、到期时间、今日是否清完;不显示备注、打手、价格(用户确认)

实现:
- 新增 internal/web/handlers_portal.go:handleCustomerPortal + buildPortal
  - 尾号查客户(CustomersByTail,已过滤归档客户)
  - 多命中时返回候选家数并要求补姓名,姓名模糊匹配(不区分大小写)
  - 月度网格:按订阅覆盖日期统计每日应打/已完成,打卡数据复用 CheckinsBetween
  - 今日汇总、最近到期日、活跃订阅数
- 新增模板 pages/portal.html(Bootstrap 卡片 + 自定义 .pcal 月历)
- 路由 GET /customer(公开,无需登录)
- app.css 增加门户与 pcal 样式
- 数据库零改动,未新增表或列

测试:
- HTTP 验证 6 条路径全部 200:落地页、仅尾号(提示补姓名)、尾号+姓名模糊、姓名不匹配、尾号不存在、翻月
- 无头浏览器实拍 shots/portal-v110.png(公开页)
- 已知待办:门户标题与主按钮的品牌色内联覆盖未生效,需下一版修正(Bootstrap 后加载覆盖了主题色)
2026-10-20 03:11:36 +08:00

244 lines
5.8 KiB
Go

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
}