Files
chun_qiu 0392f44cf1
build / build-and-test (push) Successful in 3m26s
fix(v1.11.5): 深渊日历按单周期显示 1/1 或 0/1,工作台与大屏同步
- 整段色块计数改为按当前格所在周期:cycleDone → 1/1,否则 0/1;
  不再把全局 used/total 重复到每一格。
- 抽出 blockCellText / blockCellPercent,工作台、大屏、客户自助页共用同一口径;
  通行证保持 0/1 / 1/1,深渊按所在周期 1/1 / 0/1。
- 大屏恢复通行证 / 深渊页签;screenCalSubs 补上缺失的 start(之前为零值,
  导致大屏周期判定和色阶都算错),大屏格子现在显示与工作台一致的 1/1、0/1
  以及 34%→4% 的周期色阶。
- 大屏色块样式:has-block / scr-block,亮暗两套都可读。
- 版本 1.11.5;README 的深渊说明同步,「工作台、大屏、客户自助页同一口径」。

验证:测试数据第一周期完成、第二周期未完成,工作台、 大屏、客户自助页均显示
20–26 为 1/1、27–31 为 0/1;周期浓度 34%/24%/14%/4%;gofmt / go vet /
go test 全绿。
2026-10-20 04:06:39 +08:00

391 lines
9.6 KiB
Go
Raw Permalink 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 (
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。
type portalDay struct {
Date string
Day int
InMonth bool
Today bool
Total int
Done int
// 通行证 / 深渊色块
BlockColor string
BlockText string
BlockTitle string
BlockPercent int
// Label 是读屏用的整格描述
Label string
}
// portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。
type portalSub struct {
SubView
TodayLabel string
TodayClass string
}
// portalAsk 同一尾号命中多个客户时要求补姓名。
type portalAsk struct {
Tail string
Count int
Masked []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
TodayPending int
TodayState string // done / pending / none
SubCount int
Expiring string
Masked []string
}
var portalTailRe = regexp.MustCompile(`^\d{4,8}$`)
const (
portalRateLimit = 40
portalRateWindow = time.Minute
)
var portalHits = struct {
sync.Mutex
m map[string][]time.Time
}{m: map[string][]time.Time{}}
// allowPortal 对自助查询做简单的按 IP 限流,避免尾号枚举把库打穿。
func allowPortal(remote string) bool {
host, _, err := net.SplitHostPort(remote)
if err != nil || host == "" {
host = remote
}
now := time.Now()
portalHits.Lock()
defer portalHits.Unlock()
kept := portalHits.m[host][:0]
for _, t := range portalHits.m[host] {
if now.Sub(t) < portalRateWindow {
kept = append(kept, t)
}
}
if len(kept) >= portalRateLimit {
portalHits.m[host] = kept
return false
}
portalHits.m[host] = append(kept, now)
return true
}
// handleCustomerPortal 客户自助查询页(公开,无入口链接)。
func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
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
p.Name = name
if tail == "" {
s.render(w, r, "portal", p)
return
}
if !allowPortal(r.RemoteAddr) {
p.InlineError = "查询太频繁,请稍后再试"
s.render(w, r, "portal", p)
return
}
if !portalTailRe.MatchString(tail) {
p.InlineError = "请输入 4–8 位数字手机尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p)
return
}
list, err := s.db.CustomersByTailExact(tail)
if err != nil {
p.InlineError = "读取客户失败,请稍后再试"
s.render(w, r, "portal", p)
return
}
if len(list) == 0 {
p.InlineError = "没有找到这个尾号的客户,请核对尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p)
return
}
if len(list) > 1 {
if name == "" {
p.D = &portalAsk{Tail: tail, Count: len(list), Masked: maskNames(list)}
s.render(w, r, "portal", p)
return
}
matched := matchByName(list, name)
if len(matched) == 0 {
p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下"
p.InvalidField = "name"
s.render(w, r, "portal", p)
return
}
if len(matched) > 1 {
p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)}
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 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
}
func maskNames(list []store.Customer) []string {
out := make([]string, 0, len(list))
seen := map[string]bool{}
for _, c := range list {
m := maskName(c.Name)
if seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// maskName 只保留首字,避免多客户态直接列出全名。
func maskName(name string) string {
rs := []rune(strings.TrimSpace(name))
switch len(rs) {
case 0:
return "*"
case 1:
return "*"
}
return string(rs[0]) + strings.Repeat("*", len(rs)-1)
}
// buildPortal 组装某位客户的排单视图。
func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView {
today := domain.Today()
todayStr := domain.Date(today)
warn := s.warnDays()
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))
allSubs, _ := s.db.SubsByCustomer(c.ID)
subs := make([]store.Subscription, 0, len(allSubs))
ids := make([]int64, 0, len(allSubs))
for _, sub := range allSubs {
if sub.Status == domain.StatusClosed {
continue
}
subs = append(subs, sub)
ids = append(ids, sub.ID)
}
cks, _ := s.db.CheckinsForSubs(ids, wideFrom(domain.Date(start)), wideTo(domain.Date(end)))
pauses, _ := s.db.PausesForSubs(ids)
cals := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
checkins: cks[sub.ID],
}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
cals = append(cals, cs)
}
v := &portalView{
Tail: c.Tail,
Name: c.Name,
Game: c.GameName,
SubCount: len(subs),
}
v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon)
v.Prev = portalQuery(tail, name, firstDay.AddDate(0, -1, 0).Format("2006-01"))
v.Next = portalQuery(tail, name, firstDay.AddDate(0, 1, 0).Format("2006-01"))
for _, cs := range cals {
sub := cs.sub
ev := newSubView(sub, today, warn)
ps := portalSub{SubView: ev}
if ev.StateClass == "active" && !sub.Season && !sub.Abyss && cs.covers(todayStr) {
if ci, ok := cks[sub.ID][todayStr]; ok && portalDone(ci) {
ps.TodayLabel, ps.TodayClass = "今日已完成", "active"
} else {
ps.TodayLabel, ps.TodayClass = "今日未完成", "plain"
}
}
v.Subs = append(v.Subs, ps)
if sub.EndDate != "" && sub.EndDate >= todayStr {
switch ev.Eval.State {
case domain.StateExpired, domain.StateUsedUp, domain.StateClosed:
default:
if 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}
// 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。
if pd.InMonth {
for _, cs := range cals {
sub := cs.sub
if cs.isBlock() {
if cs.blockCovers(ds) && pd.BlockText == "" {
pd.BlockColor = sub.PackageColor
pd.BlockText = blockCellText(cs, ds)
pd.BlockTitle = sub.PackageName + " · " + pd.BlockText
pd.BlockPercent = blockCellPercent(cs, d)
}
continue
}
if !cs.covers(ds) {
continue
}
pd.Total++
if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) {
pd.Done++
}
}
}
pd.Label = portalDayLabel(pd, mon)
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)
}
// 今日进度:整段型订阅算 1 项,通行证看 season_done,深渊看当天所在周期。
for _, cs := range cals {
if cs.isBlock() {
if cs.blockCovers(todayStr) {
v.TodayTotal++
if !cs.blockNeedsWork(todayStr) {
v.TodayDone++
}
}
continue
}
if !cs.covers(todayStr) {
continue
}
v.TodayTotal++
if ci, ok := cks[cs.sub.ID][todayStr]; ok && portalDone(ci) {
v.TodayDone++
}
}
v.TodayPending = v.TodayTotal - v.TodayDone
switch {
case v.TodayTotal == 0:
v.TodayState = "none"
case v.TodayPending <= 0:
v.TodayState = "done"
default:
v.TodayState = "pending"
}
return v
}
// portalDone 与管理端日历口径一致:done 和 skipped 都算当天已处理。
func portalDone(ci store.Checkin) bool {
return ci.Status == "done" || ci.Status == "skipped"
}
func portalDayLabel(pd portalDay, month int) string {
labelMonth := month
if t, err := time.Parse("2006-01-02", pd.Date); err == nil {
labelMonth = int(t.Month())
}
label := fmt.Sprintf("%d 月 %d 日", labelMonth, pd.Day)
if pd.Today {
label += ",今天"
}
if pd.BlockText != "" {
label += "," + pd.BlockTitle
}
if pd.Total > 0 {
label += fmt.Sprintf(",共 %d 单,已完成 %d 单", pd.Total, pd.Done)
}
if pd.BlockText == "" && pd.Total == 0 {
label += ",无排单"
}
return label
}
// portalQuery 用 url.Values 拼翻月链接,姓名含 & / 空格时也不会丢参数。
func portalQuery(tail, name, month string) string {
q := url.Values{}
q.Set("tail", tail)
if name != "" {
q.Set("name", name)
}
q.Set("cm", month)
return "/customer?" + q.Encode()
}