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 后加载覆盖了主题色)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{{define "content"}}
|
||||
<div class="portal-wrap">
|
||||
<header class="portal-head">
|
||||
<div>
|
||||
<h1>排单查询</h1>
|
||||
<p class="muted">输入手机尾号查看自己的排单与完成情况{{if not .D}};同一尾号有多位客户时需要再填姓名{{end}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="portal-form card" method="get" action="/customer">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-sm-4">
|
||||
<label class="form-label" for="tail">手机尾号</label>
|
||||
<input class="form-control" id="tail" name="tail" value="{{.Query}}" inputmode="numeric" maxlength="8" placeholder="例如 2300" required autofocus>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4">
|
||||
<label class="form-label" for="name">客户姓名(可选)</label>
|
||||
<input class="form-control" id="name" name="name" maxlength="32" placeholder="尾号重复时填写">
|
||||
</div>
|
||||
<div class="col-12 col-sm-4">
|
||||
<button class="btn btn-primary w-100" type="submit">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted small mb-0 mt-2">只显示排单与完成情况,不含备注与价格。如有疑问请联系代打。</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .Error}}<div class="alert alert-warning mt-3" role="alert">{{.Error}}</div>{{end}}
|
||||
|
||||
{{with .D}}
|
||||
{{if .Names}}
|
||||
<div class="card mt-3"><div class="card-body">
|
||||
<h2 class="h5">找到 {{.Count}} 位客户</h2>
|
||||
<p class="muted mb-2">同一尾号有多位客户,请在上方补填姓名(支持模糊匹配)后重新查询。</p>
|
||||
</div></div>
|
||||
{{else}}
|
||||
<section class="portal-result">
|
||||
<div class="card mt-3"><div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<h2 class="h4 mb-0">{{.Tail}} · {{.Name}}</h2>
|
||||
{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}}
|
||||
<span class="tagchip">{{.ActiveSubs}} 个订阅</span>
|
||||
{{if .Expiring}}<span class="tagchip">最近到期 {{.Expiring}}</span>{{end}}
|
||||
<span class="ms-auto">
|
||||
{{if .AllDone}}<span class="badge text-bg-success">今日已清完</span>
|
||||
{{else}}<span class="badge text-bg-warning">今日待完成 {{.TodayDone}} / {{.TodayTotal}}</span>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="portal-progress mt-3">
|
||||
<div class="bar"><i style="width:{{.TodayPct}}%"></i></div>
|
||||
<span class="muted small">今日 {{.TodayDone}} / {{.TodayTotal}} 单完成</span>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
{{if .Subs}}
|
||||
<div class="row g-3 mt-1">
|
||||
{{range .Subs}}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100"><div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<strong>{{.Name}}</strong>
|
||||
<span class="tagchip">{{.UnitLabel}}</span>
|
||||
<span class="ms-auto">{{if .TodayDone}}<span class="badge text-bg-success">今日已完成</span>{{else}}<span class="badge text-bg-secondary">今日未完成</span>{{end}}</span>
|
||||
</div>
|
||||
<div class="muted small">{{.Period}}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card mt-3"><div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">当月排单</h3>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{.Prev}}">‹</a>
|
||||
<b>{{.MonthTitle}}</b>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{.Next}}">›</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcal">
|
||||
<div class="pcal-dow"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
|
||||
{{range .Weeks}}
|
||||
<div class="pcal-row">
|
||||
{{range .}}
|
||||
<div class="pcal-cell{{if not .InMonth}} out{{end}}{{if .Today}} today{{end}}">
|
||||
<b>{{.Day}}</b>
|
||||
{{if and .InMonth .Total}}<span>{{.Done}}/{{.Total}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user